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.