Parsing JSON is a very common task for apps that need to fetch data from the Internet.
And depending on how much JSON data you need to process, you have two options:
- write all the JSON parsing code manually
- automate the process with code generation
This guide will focus on how to manually parse JSON to Dart code, including:
- encoding and decoding JSON
- defining type-safe model classes
- parsing JSON to Dart code using a factory constructor
- dealing with nullable/optional values
- data validation
- serializing back to JSON
- parsing complex/nested JSON data
- picking deep values with the deep_pick package
By the end of this article, you'll learn how to write model classes with robust JSON parsing and validation code.
And in the next article, you'll learn about JSON parsing with code generation tools, so that you don't have to write all the parsing code by hand.
sponsor

Add subscriptions to your Flutter apps. RevenueCat gives you everything you need to build, analyze, and grow IAPs with just a few lines of code.
We have a lot to cover, so let's start from the basics.
Anatomy of a JSON document
If you worked with any REST APIs before, this sample JSON response should look familiar:
{
"name": "Pizza da Mario",
"cuisine": "Italian",
"reviews": [
{
"score": 4.5,
"review": "The pizza was amazing!"
},
{
"score": 5.0,
"review": "Very friendly staff, excellent service!"
}
]
}
This simple document represents a map of key-value pairs where:
- the keys are strings
- the values can be any primitive type (such as a boolean, number, or string), or a collection (such as a list or map)
JSON data can contain both maps of key-value pairs (using
{}
) and lists (using[]
). These can be combined to create nested collections that represent complex data structures.
In fact, our example includes a list of reviews for a given restaurant:
"reviews": [
{
"score": 4.5,
"review": "The pizza was amazing!"
},
{
"score": 5.0,
"review": "Very friendly staff, excellent service!"
}
]
These are stored as a list of maps for the reviews
key, and each review is a valid JSON fragment in itself.
Encoding and Decoding JSON
When a JSON response is sent over the network, the entire payload is encoded as a string.
But inside our Flutter apps we don't want to manually extract the data from a string:
// json payload as a string
final json = '{ "name": "Pizza da Mario", "cuisine": "Italian", "reviews": [{"score": 4.5,"review": "The pizza was amazing!"},{"score": 5.0,"review": "Very friendly staff, excellent service!"}]}';
Instead, we can read the contents by decoding the JSON.
To send JSON data over the network, it first needs to be encoded or serialized. Encoding is the process of turning a data structure into a string. The opposite process is called decoding or deserialization. When you receive a JSON payload as a string, you need to decode or deserialize it before you can use it.
Decoding JSON with dart:convert
For simplicity, let's consider this small JSON payload:
// this represents some response data we get from the network, for example:
// ```
// final response = await http.get(uri);
// final jsonData = response.body
// ```
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';
To read the keys and values inside it, we first need to decode it using the dart:convert
package:
// 1. import dart:convert
import 'dart:convert';
// this represents some response data we get from the network
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';
// 2. decode the json
final parsedJson = jsonDecode(jsonData);
// 3. print the type and value
print('${parsedJson.runtimeType} : $parsedJson');
If we run this code, we get this output:
_InternalLinkedHashMap<String, dynamic> : {name: Pizza da Mario, cuisine: Italian}
In practice, the result type is the same as Map<String, dynamic>
.
_InternalLinkedHashMap is an private implementation of LinkedHashMap, which in turn implements Map.
So the keys are of type String
and the values are of type dynamic
. This makes sense because each JSON value could be a primitive type (boolean/number/string), or a collection (list or map).
In fact, jsonDecode()
is a generic method that works on any valid JSON payload, regardless of what's inside it. All it does is decode it and return a dynamic
value.
But if we work with dynamic
values in Dart we lose all the benefits of strong type-safety. A much better approach is to define some custom model classes that represent our response data, on a case-by-case basis.
Since Dart is a statically-typed language, it's important to convert JSON data into model classes that represent real-world objects (such as a recipe, an employee, etc), and make the most of the type system.
So let's see how to do this.
Parsing JSON to a Dart model class
Given this simple JSON:
{
"name": "Pizza da Mario",
"cuisine": "Italian"
}
We can write a Restaurant
class to represent it:
class Restaurant {
Restaurant({required this.name, required this.cuisine});
final String name;
final String cuisine;
}
As a result, rather than reading the data like this:
parsedJson['name']; // dynamic
parsedJson['cuisine']; // dynamic
we can read it like this:
restaurant.name; // guaranteed to be a non-nullable, immutable String
restaurant.cuisine; // guaranteed to be a non-nullable, immutable String
This is much cleaner and we can leverage the type system to get compile-time safety and avoid typos and other mistakes.
However, we haven't specified how to convert our parsedJson
to a Restaurant
object yet!
JSON to Dart: Adding a factory constructor
Let's define a factory constructor to take care of this:
factory Restaurant.fromJson(Map<String, dynamic> data) {
// note the explicit cast to String
// this is required if robust lint rules are enabled
final name = data['name'] as String;
final cuisine = data['cuisine'] as String;
return Restaurant(name: name, cuisine: cuisine);
}
A factory constructor is a good choice for JSON parsing as it lets us do some work (create variables, perform some validation) before returning the result. This is not possible with regular (generative) constructors.
Since the values of our map are
dynamic
, we explicitly cast them to our desired type (String
in this case). This is a good practice that can be enforced by using the recommended lint rules.
This is how we can use our constructor:
// type: String
final jsonData = '{ "name": "Pizza da Mario", "cuisine": "Italian" }';
// type: dynamic (runtime type: _InternalLinkedHashMap<String, dynamic>)
final parsedJson = jsonDecode(jsonData);
// type: Restaurant
final restaurant = Restaurant.fromJson(parsedJson);
Much better. Now the rest of our code can use Restaurant
and get all the advantages of strong type-safety in Dart.
JSON to Dart with Null Safety
Sometimes we need to parse some JSON that may or may not have a certain key-value pair.
For example, suppose we have an optional field telling us when a restaurant was first opened:
{
"name": "Ezo Sushi",
"cuisine": "Japanese",
"year_opened": 1990
}
If the year_opened
field is optional, we can represent it with a nullable variable in our model class.
Here's an updated implementation for the Restaurant
class:
class Restaurant {
Restaurant({required this.name, required this.cuisine, this.yearOpened});
final String name; // non-nullable
final String cuisine; // non-nullable
final int? yearOpened; // nullable
factory Restaurant.fromJson(Map<String, dynamic> data) {
final name = data['name'] as String; // cast as non-nullable String
final cuisine = data['cuisine'] as String; // cast as non-nullable String
final yearOpened = data['year_opened'] as int?; // cast as nullable int
return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
}
}
As a general rule, we should map optional JSON values to nullable Dart properties. Alternatively, we can use non-nullable Dart properties with a sensible default value, like in this example:
// note: all the previous properties have been omitted for simplicity
class Restaurant {
Restaurant({
// 1. required
required this.hasIndoorSeating,
});
// 2. *non-nullable*
final bool hasIndoorSeating;
factory Restaurant.fromJson(Map<String, dynamic> data) {
// 3. cast as *nullable* bool
final hasIndoorSeating = data['has_indoor_seating'] as bool?;
return Restaurant(
// 4. use ?? operator to provide a default value
hasIndoorSeating: hasIndoorSeating ?? true,
);
}
}
Note how in this case we use the null-coalescing operator (??
) to provide a default value.
Data validation
One benefit of using factory constructors is that we can do some additional validation if needed.
For example, we could write some defensive code that throws an UnsupportedError
if a required value is missing.
factory Restaurant.fromJson(Map<String, dynamic> data) {
// casting as a nullable String so we can do an explicit null check
final name = data['name'] as String?;
if (name == null) {
throw UnsupportedError('Invalid data: $data -> "name" is missing');
}
// casting as a nullable String so we can do an explicit null check
final cuisine = data['cuisine'] as String?;
if (cuisine == null) {
throw UnsupportedError('Invalid data: $data -> "cuisine" is missing');
}
final yearOpened = data['year_opened'] as int?;
// thanks to the if statements above, name and cuisine are guaranteed to be non-null here
return Restaurant(name: name, cuisine: cuisine, yearOpened: yearOpened);
}
In general, it's our job as the API consumer to work out, for each value:
- its type (
String
,int
, etc.) - if it's optional or not (nullable vs non-nullable)
- what range of values are allowed
This will make our JSON parsing code more robust. And we won't have to deal with invalid data in our widget classes, because all the validation is done upfront.
To make your code production-ready, consider writing unit tests to test all possible edge cases for all your model classes.
JSON Serialization with toJson()
Parsing JSON is useful, but sometimes we want to convert a model object back to JSON and send it over the network.
To do this, we can define a toJson()
method for our Restaurant
class:
// note the return type
Map<String, dynamic> toJson() {
// return a map literal with all the non-null key-value pairs
return {
'name': name,
'cuisine': cuisine,
// here we use collection-if to account for null values
if (yearOpened != null) 'year_opened': yearOpened,
};
}
And we can use this like so:
// given a Restaurant object
final restaurant = Restaurant(name: "Patatas Bravas", cuisine: "Spanish");
// convert it to map
final jsonMap = restaurant.toJson();
// encode it to a JSON string
final encodedJson = jsonEncode(jsonMap);
// then send it as a request body with any networking package
Parsing Nested JSON: List of Maps
Now that we understand the basics of JSON parsing and validation, let's go back to our initial example and see how to parse it:
{
"name": "Pizza da Mario",
"cuisine": "Italian",
"reviews": [
{
"score": 4.5,
"review": "The pizza was amazing!"
},
{
"score": 5.0,
"review": "Very friendly staff, excellent service!"
}
]
}
We want to use model classes and type-safety all the way, so let's define a Review
class:
class Review {
Review({required this.score, this.review});
// non-nullable - assuming the score field is always present
final double score;
// nullable - assuming the review field is optional
final String? review;
factory Review.fromJson(Map<String, dynamic> data) {
final score = data['score'] as double;
final review = data['review'] as String?;
return Review(score: score, review: review);
}
Map<String, dynamic> toJson() {
return {
'score': score,
// here we use collection-if to account for null values
if (review != null) 'review': review,
};
}
}
Then we can update the Restaurant
class to include a list of reviews:
class Restaurant {
Restaurant({
required this.name,
required this.cuisine,
this.yearOpened,
required this.reviews,
});
final String name;
final String cuisine;
final int? yearOpened;
final List<Review> reviews;
}
And we can also update the factory constructor:
factory Restaurant.fromJson(Map<String, dynamic> data) {
final name = data['name'] as String;
final cuisine = data['cuisine'] as String;
final yearOpened = data['year_opened'] as int?;
// cast to a nullable list as the reviews may be missing
final reviewsData = data['reviews'] as List<dynamic>?;
// if the reviews are not missing
final reviews = reviewsData != null
// map each review to a Review object
? reviewsData.map((reviewData) => Review.fromJson(reviewData))
// map() returns an Iterable so we convert it to a List
.toList()
// use an empty list as fallback value
: <Review>[];
// return result passing all the arguments
return Restaurant(
name: name,
cuisine: cuisine,
yearOpened: yearOpened,
reviews: reviews,
);
}
Here's a breakdown of all the new code:
- the reviews may be missing, hence we cast to a nullable
List
- the values in the list could have any type, so we use
List<dynamic>
- we use the
.map()
operator to convert eachdynamic
value to aReview
object usingReview.fromJson()
- if the reviews are missing, we use an empty list (
<Review>[]
) as a fallback
This specific implementation makes some assumptions about what may or may not be
null
, what fallback values to use etc. You need to write the parsing code that is most appropriate for your use case.
Serializing Nested Models
As a last step, here's the toJson()
method to convert a Restaurant
(and all its reviews) back into a Map
:
Map<String, dynamic> toJson() {
return {
'name': name,
'cuisine': cuisine,
if (yearOpened != null) 'year_opened': yearOpened,
'reviews': reviews.map((review) => review.toJson()).toList(),
};
}
Note how we convert the List<Review>
back to a List<Map<String, dynamic>>
, as we need to serialize all nested values as well (and not just the Restaurant
class itself).
With the code above, we can create a Restaurant
object and convert it back into a map that can be encoded and printed or sent over the network:
final restaurant = Restaurant(
name: 'Pizza da Mario',
cuisine: 'Italian',
reviews: [
Review(score: 4.5, review: 'The pizza was amazing!'),
Review(score: 5.0, review: 'Very friendly staff, excellent service!'),
],
);
final encoded = jsonEncode(restaurant.toJson());
print(encoded);
// output: {"name":"Pizza da Mario","cuisine":"Italian","reviews":[{"score":4.5,"review":"The pizza was amazing!"},{"score":5.0,"review":"Very friendly staff, excellent service!"}]}
Picking Deep Values
Parsing a whole JSON document into type-safe model classes is a very common use case.
But sometimes we just want to read some specific values that may be deeply nested.
Let's consider our sample JSON once again:
{
"name": "Pizza da Mario",
"cuisine": "Italian",
"reviews": [
{
"score": 4.5,
"review": "The pizza was amazing!"
},
{
"score": 5.0,
"review": "Very friendly staff, excellent service!"
}
]
}
If we wanted to get the score for the first review, we could do it like this:
final decodedJson = jsonDecode(jsonData); // dynamic
final score = decodedJson['reviews'][0]['score'] as double;
This is valid Dart code because the decodedJson
variable is dynamic
and we can use the subscript operator with it ([]
).
But the code above is neither null safe nor type safe and we have to explicitly cast the parsed value to the type we want (double
).
How can we improve this?
The deep_pick package
The deep_pick package simplifies JSON parsing with a type-safe API.
Once installed, we can use it to get the value we want without manual casts:
import 'dart:convert';
import 'package:deep_pick/deep_pick.dart';
final decodedJson = jsonDecode(jsonData); // dynamic
final score = pick(decodedJson, 'reviews', 0, 'score').asDoubleOrThrow();
deep_pick offers a variety of flexible APIs that we can use to parse primitive types, lists, maps, DateTime objects, and more. Read the documentation for more info.
Bonus: adding a toString() method
When working with model classes it's very useful to provide a toString()
method so that they can be easily printed to console.
And since we already have a toJson()
method, we can leverage it like so:
@override
String toString() => toJson().toString();
As a result, we can print our restaurant directly like this:
print(restaurant);
// output: {name: Pizza da Mario, cuisine: Italian, reviews: [{score: 4.5, review: The pizza was amazing!}, {score: 5.0, review: Very friendly staff, excellent service!}]}
It would also be nice if we could compare our model classes using the
==
operator, as is often required when writing unit tests. To find out how to do this, check out the Equatable package.
Note about performance
When you parse small JSON documents, your application is likely to remain responsive and not experience performance problems.
But parsing very large JSON documents can result in expensive computations that are best done in the background on a separate Dart isolate. The official docs have a good guide about this:
sponsor

Add subscriptions to your Flutter apps. RevenueCat gives you everything you need to build, analyze, and grow IAPs with just a few lines of code.
Conclusion
JSON serialization is a very mundane task. But if we want our apps to work correctly, it's very important that we do it right and pay attention to details:
- use
jsonEncode()
andjsonDecode()
from'dart:convert'
to serialize JSON data - create model classes with
fromJson()
andtoJson()
for all domain-specific JSON objects in your app - add explicit casts, validation, and null checks inside
fromJson()
to make the parsing code more robust - for nested JSON data (lists of maps), apply the
fromJson()
andtoJson()
methods - consider using the deep_pick package to parse JSON in a type-safe way
While the example JSON we used as reference wasn't too complex, we still ended up with a considerable amount of code:
If you have a lot of different model classes, or each class has a lot of properties, writing all the parsing code by hand becomes time-consuming and error-prone.
In such cases, code generation is a much better option and this article explains how to use it:
And if you need to parse large JSON data, you should do so in a separate isolate for best performance. This article covers all the details:
Flutter Foundations Course Now Available
I launched a brand new course where you will learn about state management, app architecture, navigation, testing, and much more: