Flutter tutorials and courses by Andrea Bizzotto

How to Parse JSON in Dart/Flutter: The Essential Guide

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:

  1. write all the JSON parsing code manually
  2. 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.

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 each dynamic value to a Review object using Review.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:

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() and jsonDecode() from 'dart:convert' to serialize JSON data
  • create model classes with fromJson() and toJson() 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() and toJson() 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 we'll explore this in the next article.

Happy coding!

Want more?

Fast-track your Flutter learning with over 40 hours of in-depth content.