JSON in Flutter: jsonDecode, json_serializable, and http Package

Last updated:

Flutter parses JSON with jsonDecode(string) from dart:convert — it returns dynamic, so you must cast fields manually or generate model classes. The json_serializable package generates fromJson() / toJson() from a @JsonSerializable annotation, eliminating hand-written casting for every field. The http package fetches JSON from APIs: http.get(Uri.parse(url)) returns a Response with a body string you pass to jsonDecode. For null-safety, always declare nullable fields as String? and use json['field'] as String? casts. This guide covers 5 topics: manual jsonDecode with casting, json_serializable code generation, http package for API calls, null-safe nested objects, and freezed for immutable models.

Generate Dart model classes from JSON

Paste your API response into Jsonic's Dart converter to instantly scaffold a Flutter model class with fromJson() and toJson().

Open JSON → Dart Converter

Parse JSON with jsonDecode

Bottom line: import dart:convert and call jsonDecode(string) to turn a JSON string into a Dart value. The return type is dynamic — for a JSON object you get a Map<String, dynamic>, for a JSON array a List<dynamic>. Cast each field explicitly with as String, as int, or the nullable as String? variant.

Always wrap jsonDecode in a try/catch — it throws a FormatException when the string is not valid JSON. For nested objects, cast the inner map before accessing its fields. For JSON arrays of objects, cast to List<dynamic> and then map each element to your model using fromJson. Avoid storing the raw dynamic value across your app — cast to a typed model as soon as possible so Dart's type system catches mistakes at compile time.

import 'dart:convert';

// Parse a simple JSON object
final String jsonString = '{"id": 1, "name": "Alice", "active": true}';
final Map<String, dynamic> user =
    jsonDecode(jsonString) as Map<String, dynamic>;

final int id     = user['id'] as int;
final String name = user['name'] as String;
final bool active = user['active'] as bool;

// With error handling
Map<String, dynamic> parseUser(String body) {
  try {
    return jsonDecode(body) as Map<String, dynamic>;
  } on FormatException catch (e) {
    throw Exception('Invalid JSON: ${e.message}');
  }
}

// Parse a JSON array of objects
final String listJson =
    '[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]';
final List<dynamic> raw = jsonDecode(listJson) as List<dynamic>;
final List<Map<String, dynamic>> users =
    raw.cast<Map<String, dynamic>>();

// Nested objects — cast the inner map before field access
final String nested = '{"user":{"id":1,"name":"Alice"},"score":99}';
final Map<String, dynamic> payload =
    jsonDecode(nested) as Map<String, dynamic>;
final Map<String, dynamic> innerUser =
    payload['user'] as Map<String, dynamic>;
final String innerName = innerUser['name'] as String;

// Encode back to JSON string
final String encoded = jsonEncode({'id': 1, 'name': 'Alice'});
// → '{"id":1,"name":"Alice"}'

Generate Model Classes with json_serializable

Bottom line: add json_annotation to dependencies and json_serializable + build_runner to dev_dependencies in pubspec.yaml. Annotate your class with @JsonSerializable(), declare a fromJson factory and a toJson method with generated implementations, then run dart run build_runner build. The tool writes a *.g.dart file with all casting handled automatically.

Use @JsonKey(name: 'snake_case') on a field to map a differently-named JSON key to a camelCase Dart property. Set @JsonSerializable(explicitToJson: true) on classes with nested custom objects so toJson() recursively serializes them instead of producing a raw Map. Re-run build_runner every time you add, rename, or remove an annotated field — the generated file is not updated automatically.

# pubspec.yaml
dependencies:
  json_annotation: ^4.8.0

dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.7.0

// user.dart
import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart'; // generated file

@JsonSerializable(explicitToJson: true)
class User {
  final int id;
  final String name;

  @JsonKey(name: 'created_at') // maps JSON 'created_at' → Dart 'createdAt'
  final String createdAt;

  final String? bio; // nullable — absent in JSON is treated as null

  const User({
    required this.id,
    required this.name,
    required this.createdAt,
    this.bio,
  });

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

// Run code generation:
// dart run build_runner build --delete-conflicting-outputs

// Usage
final user = User.fromJson(jsonDecode(responseBody) as Map<String, dynamic>);
print(user.name);          // Alice
print(user.toJson());      // {id: 1, name: Alice, created_at: ...}

Fetch JSON from APIs with the http Package

Bottom line: add http: ^1.0.0 to dependencies and import it as import 'package:http/http.dart' as http. Call await http.get(Uri.parse(url)) to make a GET request — always await the Future. The returned http.Response has a statusCode int and a body string. Check response.statusCode == 200 before parsing the body with jsonDecode.

For POST requests, use http.post() with headers: {'Content-Type': 'application/json'} and body: jsonEncode(payload). Wrap network calls in try/catch to handle SocketException (no connectivity) and TimeoutException. In Flutter, network calls must run off the main isolate for large payloads — use compute() or spawn an isolate when parsing JSON exceeding ~10 KB to avoid UI jank.

import 'dart:convert';
import 'package:http/http.dart' as http;

// GET request — check statusCode before parsing
Future<User> fetchUser(int id) async {
  final response = await http.get(
    Uri.parse('https://api.example.com/users/$id'),
    headers: {'Accept': 'application/json'},
  );

  if (response.statusCode == 200) {
    return User.fromJson(
      jsonDecode(response.body) as Map<String, dynamic>,
    );
  } else {
    throw Exception('Failed to load user: ${response.statusCode}');
  }
}

// POST with a JSON body
Future<User> createUser(String name) async {
  final response = await http.post(
    Uri.parse('https://api.example.com/users'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({'name': name}),
  );

  if (response.statusCode == 201) {
    return User.fromJson(
      jsonDecode(response.body) as Map<String, dynamic>,
    );
  } else {
    throw Exception('Create failed: ${response.statusCode}');
  }
}

// With timeout and error handling
Future<List<User>> fetchUsers() async {
  try {
    final response = await http
        .get(Uri.parse('https://api.example.com/users'))
        .timeout(const Duration(seconds: 10));

    if (response.statusCode != 200) {
      throw Exception('HTTP ${response.statusCode}');
    }

    final List<dynamic> list =
        jsonDecode(response.body) as List<dynamic>;
    return list
        .cast<Map<String, dynamic>>()
        .map(User.fromJson)
        .toList();
  } on SocketException {
    throw Exception('No internet connection');
  } on TimeoutException {
    throw Exception('Request timed out');
  }
}

Handle Null-Safety in JSON

Bottom line: Dart's null safety requires you to decide at the type level whether each JSON field can be absent or null. Declare optional fields as nullable types (String?, int?) and use nullable casts (json['field'] as String?) when reading from a Map<String, dynamic>. The ?? operator supplies a fallback: json['field'] as String? ?? 'default'.

A non-nullable cast like json['field'] as String throws a TypeError at runtime if the field is absent or the value is null. Use this only for fields your API contract guarantees will always be present. For fields that might be missing in some API versions, prefer nullable types and handle the absence explicitly. When using json_serializable, mark optional fields with ? in the type declaration — the generated code will emit the correct nullable cast automatically.

import 'dart:convert';

// Nullable cast — safe when field may be absent
final Map<String, dynamic> json =
    jsonDecode('{"name":"Alice","bio":null}') as Map<String, dynamic>;

final String name     = json['name'] as String;      // required — throws if missing
final String? bio     = json['bio'] as String?;       // nullable — null if absent
final int? age        = json['age'] as int?;          // absent key → null, not error
final String display  = json['display'] as String? ?? 'Unknown'; // fallback

// Check before using
if (bio != null) {
  print(bio.toUpperCase()); // safe — compiler knows bio is non-null here
}

// Class with mixed required / optional fields
class Profile {
  final String username;  // required
  final String? website;  // optional
  final int? followerCount;

  Profile({
    required this.username,
    this.website,
    this.followerCount,
  });

  factory Profile.fromJson(Map<String, dynamic> json) {
    return Profile(
      username: json['username'] as String,
      website: json['website'] as String?,
      followerCount: json['follower_count'] as int?,
    );
  }
}

// Handling missing keys explicitly with containsKey
final bool hasEmail = json.containsKey('email');
final String email  = hasEmail
    ? json['email'] as String
    : 'no-reply@example.com';

Nested Objects and Lists

Bottom line: for nested JSON objects, cast the inner map and call fromJson recursively. For JSON arrays of objects, cast to List<dynamic> then map each element. With json_serializable and explicitToJson: true, nested custom objects are serialized recursively — without this flag, toJson() emits the raw Map instead of calling the nested object's own toJson().

Mixed arrays (where elements may be strings or objects) require extra care: iterate with a type check before casting. For deeply nested structures, consider flattening the model in Dart even if the JSON is deeply nested — use @JsonKey with a custom fromJson function to extract deep fields. Keep model classes flat when possible to simplify serialization and reduce casting chains.

import 'dart:convert';
import 'package:json_annotation/json_annotation.dart';

part 'models.g.dart';

@JsonSerializable(explicitToJson: true) // ← needed for nested toJson()
class Address {
  final String street;
  final String city;

  const Address({required this.street, required this.city});

  factory Address.fromJson(Map<String, dynamic> json) =>
      _$AddressFromJson(json);
  Map<String, dynamic> toJson() => _$AddressToJson(this);
}

@JsonSerializable(explicitToJson: true)
class UserWithAddress {
  final int id;
  final String name;
  final Address address;           // nested object
  final List<String> tags;         // list of primitives
  final List<Address> locations;   // list of nested objects

  const UserWithAddress({
    required this.id,
    required this.name,
    required this.address,
    required this.tags,
    required this.locations,
  });

  factory UserWithAddress.fromJson(Map<String, dynamic> json) =>
      _$UserWithAddressFromJson(json);
  Map<String, dynamic> toJson() => _$UserWithAddressToJson(this);
}

// Manual approach (no code gen) — nested fromJson
UserWithAddress parseManual(String body) {
  final json = jsonDecode(body) as Map<String, dynamic>;
  final addrMap = json['address'] as Map<String, dynamic>;
  final tagsList = (json['tags'] as List<dynamic>).cast<String>();
  final locationsList = (json['locations'] as List<dynamic>)
      .cast<Map<String, dynamic>>()
      .map(Address.fromJson)
      .toList();

  return UserWithAddress(
    id: json['id'] as int,
    name: json['name'] as String,
    address: Address.fromJson(addrMap),
    tags: tagsList,
    locations: locationsList,
  );
}

Freezed for Immutable Models

Bottom line: Freezed generates immutable value types with copyWith(), structural equality (== and hashCode), and toString() on top of what json_serializable provides. Add freezed_annotation to dependencies and freezed to dev_dependencies. Annotate with @freezed and define fields through a factory constructor — Freezed handles the rest.

Freezed's sealed union types model API responses that can be multiple distinct shapes — for example a Result type with Result.success(data) and Result.failure(error) factories, exhaustively switched with .when(). This pattern eliminates the need for null checks or separate boolean flags to represent states. The trade-off is a longer build step (two generator passes: json_serializable then freezed) and a steeper learning curve than plain json_serializable alone.

# pubspec.yaml
dependencies:
  freezed_annotation: ^2.4.0
  json_annotation: ^4.8.0

dev_dependencies:
  build_runner: ^2.4.0
  freezed: ^2.4.0
  json_serializable: ^6.7.0

// user.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    String? bio,
    @Default([]) List<String> tags, // default value for nullable/absent
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) =>
      _$UserFromJson(json);
}

// copyWith — create a modified copy without mutating
final alice = User(id: 1, name: 'Alice');
final updated = alice.copyWith(name: 'Alicia', bio: 'Developer');

// Structural equality (== works on value, not reference)
print(alice == User(id: 1, name: 'Alice')); // true

// Sealed union — model API response states
@freezed
class ApiResult<T> with _$ApiResult<T> {
  const factory ApiResult.success(T data) = Success<T>;
  const factory ApiResult.failure(String message) = Failure<T>;
}

// Exhaustive switch with .when()
final result = ApiResult.success(alice);
result.when(
  success: (data) => print('Got user: ${data.name}'),
  failure: (msg)  => print('Error: $msg'),
);

// Run code generation:
// dart run build_runner build --delete-conflicting-outputs

FAQ

How do I parse a JSON string in Flutter?

Use jsonDecode(string) from dart:convert. It returns dynamic — cast to Map<String, dynamic> for objects or List<dynamic> for arrays. Access fields with json['key'] as String (non-nullable) or json['key'] as String? (nullable). Wrap in try/catch for FormatException when the input is malformed.

What is json_serializable and how do I use it in Flutter?

json_serializable is a Dart code-generation package. Add it to dev_dependencies alongside build_runner, annotate your model class with @JsonSerializable(), add a fromJson factory and toJson method, then run dart run build_runner build. The generated *.g.dart file contains all casting logic. Use @JsonKey(name: 'snake_case') to remap field names.

How do I fetch JSON from an API in Flutter?

Add the http package to pubspec.yaml. Import as import 'package:http/http.dart' as http. Call await http.get(Uri.parse(url)), check response.statusCode == 200, then pass response.body to jsonDecode() or your model's fromJson(). For POST requests, use http.post() with headers: {'Content-Type': 'application/json'} and body: jsonEncode(payload).

How do I handle null values in Flutter JSON parsing?

Declare optional fields as nullable types (String?, int?). Use nullable casts: json['field'] as String? — this returns null if the key is absent or the value is null, without throwing. Use ?? for defaults: json['field'] as String? ?? 'fallback'. Never use a non-nullable cast for a field that might be missing from the API response.

What is Freezed in Flutter?

Freezed is a code-generation package that creates immutable value classes with copyWith(), structural ==, hashCode, and toString() automatically. It extends json_serializable and also supports sealed union types via multiple factory constructors, exhaustively matched with .when(). Run dart run build_runner build to generate both *.freezed.dart and *.g.dart files.

How do I convert a Dart object to JSON?

Call jsonEncode(object) from dart:convert. For custom classes, implement a toJson() method returning Map<String, dynamic>jsonEncode calls it automatically. With json_serializable, the generated _$ClassToJson(this) function handles all field mapping. Use jsonEncode(model.toJson()) as the body in http.post() for API requests.

Further reading and primary sources

  • dart:convert libraryOfficial Dart API reference for dart:convert — jsonDecode, jsonEncode, JsonDecoder, JsonEncoder, and related classes
  • json_serializable on pub.devDart code-generation package that generates fromJson/toJson from @JsonSerializable annotations — changelog, API docs, and configuration options
  • Freezed on pub.devCode-generation package for immutable classes, copyWith, union types, and pattern matching — builds on top of json_serializable
  • Flutter JSON serialization guideOfficial Flutter documentation covering manual JSON decoding, json_serializable setup, and when to use each approach
  • http package on pub.devDart HTTP client library used to make GET/POST requests and handle API responses in Flutter applications