Serialization
Converting between JSON and Dart objects — from manual serialization to the json_serializable package.
Serialization is the process of converting Dart objects to JSON strings (and vice versa). It's essential for:
- Sending data to an API (POST/PUT requests)
- Receiving data from an API (GET responses)
- Saving data to local storage
- Passing data between screens
🎯 Two Directions
-
Deserialization
– JSON → Dart object (
fromJson) -
Serialization
– Dart object → JSON (
toJson)
There are two main approaches to serialization in Flutter:
-
Manual Serialization
– Writing
fromJsonandtoJsonmethods by hand -
Code Generation
– Using
json_serializableto auto-generate the code
Manual serialization involves writing
fromJson
and
toJson
methods yourself.
It's simple but becomes tedious for large models.
class User {
final int id;
final String name;
final String email;
final bool isActive;
final List<String> tags;
const User({
required this.id,
required this.name,
required this.email,
required this.isActive,
required this.tags,
});
// --- Deserialization: JSON → Dart ---
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
isActive: json['isActive'] as bool,
tags: (json['tags'] as List<dynamic>).cast<String>(),
);
}
// --- Serialization: Dart → JSON ---
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'isActive': isActive,
'tags': tags,
};
}
}
✅ Pros & Cons
- Pros: Simple, no dependencies, full control
- Cons: Boilerplate-heavy, error-prone for large models
- Best for: Small projects or simple models
json_serializable
is the
recommended approach
for production apps. It uses code generation to
automatically create
fromJson
and
toJson
methods.
🎯 Why json_serializable?
- Type-safe – Catches errors at compile time
- Less boilerplate – Write only the model fields
- Handles nested objects – Works with nested models
- Configurable – Supports custom field names
- Null safety – Full support for null-safe Dart
To use
json_serializable
, you need to add several dependencies and run a build command.
pubspec.yaml
dart run build_runner build
dependencies:
flutter:
sdk: flutter
json_annotation: ^4.8.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.0
json_serializable: ^6.7.0
# One-time generation
dart run build_runner build
# Watch for changes and auto-generate
dart run build_runner watch
Here's how to define a model class with
json_serializable
annotations.
import
'package:json_annotation/json_annotation.dart'
;
// Part file that will be generated
part
'user.g.dart'
;
@JsonSerializable
(
// 👈 This triggers code generation
explicitToJson:
true
,
fieldRename: FieldRename.snake,
)
class
User
{
final
int
id;
final
String name;
final
String email;
@JsonKey
(name:
'is_active'
)
// 👈 Custom JSON key
final
bool
isActive;
final
List<String> tags;
final
Address address;
// Nested object
const
User({
required
this
.id,
required
this
.name,
required
this
.email,
required
this
.isActive,
required
this
.tags,
required
this
.address,
});
// Generated by json_serializable
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic>
toJson
() => _$UserToJson(
this
);
}
// Another model for nested objects
@JsonSerializable
()
class
Address
{
final
String street;
final
String city;
final
String zipCode;
const
Address({
required
this
.street,
required
this
.city,
required
this
.zipCode,
});
factory Address.fromJson(Map<String, dynamic> json) => _$AddressFromJson(json);
Map<String, dynamic>
toJson
() => _$AddressToJson(
this
);
}
// AUTO-GENERATED - DO NOT EDIT
User _$UserFromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
isActive: json['is_active'] as bool,
tags: (json['tags'] as List<dynamic>).cast<String>(),
address: Address.fromJson(json['address'] as Map<String, dynamic>),
);
}
Map<String, dynamic> _$UserToJson(User instance) => {
'id': instance.id,
'name': instance.name,
'email': instance.email,
'is_active': instance.isActive,
'tags': instance.tags,
'address': instance.address.toJson(),
};
json_annotation
,
json_serializable
,
and
build_runner
to your
pubspec.yaml
.
@JsonSerializable()
annotation.
Use
@JsonKey(name: '...')
for custom field names.
part 'filename.g.dart';
at the top of your file.
factory User.fromJson(...) => _$UserFromJson(...);
dart run build_runner build
to generate the
.g.dart
files.
Here's a complete example using
json_serializable
with a nested model.
import
'package:json_annotation/json_annotation.dart'
;
part
'user.g.dart'
;
@JsonSerializable
(explicitToJson:
true
)
class
User
{
final
int
id;
final
String name;
final
String email;
final
Address address;
const
User({
required
this
.id,
required
this
.name,
required
this
.email,
required
this
.address,
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic>
toJson
() => _$UserToJson(
this
);
}
@JsonSerializable
()
class
Address
{
final
String street;
final
String city;
final
String zipCode;
const
Address({
required
this
.street,
required
this
.city,
required
this
.zipCode,
});
factory Address.fromJson(Map<String, dynamic> json) => _$AddressFromJson(json);
Map<String, dynamic>
toJson
() => _$AddressToJson(
this
);
}
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'models/user.dart';
class UserService {
final http.Client client;
UserService({http.Client? client}) : client = client ?? http.Client();
Future<List<User>> fetchUsers() async {
final response = await client.get(
Uri.parse('https://jsonplaceholder.typicode.com/users'),
headers: {'Accept': 'application/json'},
);
if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body);
return jsonList.map((json) => User.fromJson(json)).toList();
} else {
throw Exception('Failed to load users');
}
}
}
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Users App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: UserListScreen(),
);
}
}
class UserListScreen extends StatefulWidget {
@override
State<UserListScreen> createState() => _UserListScreenState();
}
class _UserListScreenState extends State<UserListScreen> {
late Future<List<User>> futureUsers;
final UserService _service = UserService();
@override
void initState() {
super.initState();
futureUsers = _service.fetchUsers();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('👤 Users'), centerTitle: true),
body: FutureBuilder<List<User>>(
future: futureUsers,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: Colors.red),
SizedBox(height: 16),
Text('Error: ${snapshot.error}'),
ElevatedButton(
onPressed: () =>
setState(() => futureUsers = _service.fetchUsers()),
child: Text('Retry'),
),
],
),
);
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return Center(child: Text('No users found'));
}
final users = snapshot.data!;
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: CircleAvatar(child: Text(user.id.toString())),
title: Text(user.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(user.email, style: TextStyle(fontSize: 12)),
Text(
'${user.address.city}, ${user.address.street}',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
);
},
);
},
),
);
}
}
Manual Serialization
- No dependencies needed
- Full control over parsing
- Simple to understand
- Becomes tedious for large models
- Error-prone when fields change
- No compile-time validation
json_serializable
- Requires additional dependencies
- Auto-generates boilerplate code
- Type-safe at compile time
- Handles nested objects automatically
- Easy to update when fields change
- Recommended for production apps
💡 Recommendation
Use json_serializable for all production apps. The initial setup takes a few minutes, but it saves hours of writing boilerplate and prevents bugs. Manual serialization is fine for small projects or simple models with 1-2 fields.
After adding or changing annotations, you must run
dart run build_runner build
to regenerate the
.g.dart
files.
Use
dart run build_runner watch
to auto-generate files when you save changes.
Without
part 'filename.g.dart';
, the generated code won't be accessible.
Add
part 'filename.g.dart';
at the top of your file after the imports.
If your model contains nested objects, you need
explicitToJson: true
and
the nested objects must also have
toJson
methods.
Add
@JsonSerializable(explicitToJson: true)
to the parent model.
🎯 Key Takeaway
JSON serialization is essential for API communication. json_serializable is the recommended approach for production Flutter apps — it's type-safe, reduces boilerplate, and handles complex nested objects. Always run build_runner after making changes.