Files, JSON and HTTP in daily Dart

Read and write files with dart:io, decode JSON safely, make HTTP requests with timeouts, and retry the failures worth retrying.

Files and directories

import 'dart:convert';
import 'dart:io';

Future<void> main() async {
  // read a whole file as text; encoding defaults to UTF-8
  final file = File('data/config.json');
  if (await file.exists()) {
    final text = await file.readAsString();
    print(text.length);
  }

  // write, creating the parent directory first
  final out = File('out/report.txt');
  await out.parent.create(recursive: true);
  await out.writeAsString('header\n', mode: FileMode.write);

  // append to an existing file
  await out.writeAsString('row 1\n', mode: FileMode.append);

  // read line by line without loading the whole file
  await for (final line in file.openRead().transform(utf8.decoder).transform(const LineSplitter())) {
    if (line.contains('ERROR')) print(line);
  }

  // walk a directory tree
  await for (final entity in Directory('data').list(recursive: true)) {
    if (entity is File && entity.path.endsWith('.csv')) {
      print(await entity.length());
    }
  }

  // path manipulation with the platform separator handled for you
  final p = '${Directory.current.path}${Platform.pathSeparator}data';
  print(p);

  // delete and rename
  if (await out.exists()) await out.delete();
  print(Platform.isWindows);
}
  • All of dart:io is asynchronous and returns Futures. There are no synchronous variants worth using except existsSync in a script's setup code.
  • Reading a very large file with readAsString holds it entirely in memory. Use openRead with a transformer for streaming.
  • FileMode.append adds to the end; FileMode.write truncates. Forgetting this loses the file's contents.
  • FileSystemException carries the OS error code and the path, which is what you want in a log.
  • Platform.pathSeparator and Directory.current keep a script working on Windows and on Linux.

JSON decoding and encoding

import 'dart:convert';

class Order {
  Order(this.id, this.sku, this.quantity);

  final String id;
  final String sku;
  final int quantity;

  // a factory from JSON: validate as you parse, and fail with a useful message
  factory Order.fromJson(Map<String, dynamic> json) {
    final id = json['id'];
    final sku = json['sku'];
    final qty = json['quantity'];
    if (id is! String || sku is! String || qty is! int) {
      throw FormatException('unexpected order shape', jsonEncode(json));
    }
    return Order(id, sku, qty);
  }

  Map<String, dynamic> toJson() => {'id': id, 'sku': sku, 'quantity': quantity};

  @override
  String toString() => 'Order($id, $sku, $quantity)';
}

void main() {
  const raw = '''
  {"id":"o1","sku":"ABC-1","quantity":2,
   "lines":[{"sku":"ABC-1","qty":2}]}
  ''';

  final decoded = jsonDecode(raw) as Map<String, dynamic>;
  final order = Order.fromJson(decoded);
  print(order);

  // nested access requires casts; jsonDecode gives dynamic, not a typed tree
  final lines = (decoded['lines'] as List).cast<Map<String, dynamic>>();
  print(lines.map((l) => l['sku']).toList());

  // encoding: pass toEncodable for types the encoder does not know
  print(jsonEncode(order));
  print(jsonEncode({'items': [order]}, toEncodable: (o) => o is Order ? o.toJson() : o.toString()));

  // a stream of JSON objects: one per line, decoded as they arrive
  final objects = const LineSplitter()
      .convert('{"a":1}\n{"a":2}\n')
      .map((l) => jsonDecode(l) as Map<String, dynamic>);
  print(objects.map((m) => m['a']).toList());

  // decoding failure is a FormatException, not a null
  try {
    jsonDecode('{not json}');
  } on FormatException catch (e) {
    print('bad json at offset ${e.offset}: ${e.message}');
  }
}
💡
Write the fromJson factory by hand for anything that crosses a trust boundary, and check each field's type with is. Generated serialisation code is convenient, but a hand-written parser gives you the validation and the error message that a production service needs.

HTTP requests

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

Future<List<String>> fetchSkus(http.Client client) async {
  final uri = Uri.https('api.example.com', '/v1/products', {'limit': '50', 'q': 'abc'});

  try {
    final response = await client
        .get(uri, headers: {'Accept': 'application/json'})
        .timeout(const Duration(seconds: 10));

    if (response.statusCode != 200) {
      throw HttpException('unexpected ${response.statusCode}: ${response.body}');
    }
    final body = jsonDecode(response.body) as Map<String, dynamic>;
    final items = (body['items'] as List).cast<Map<String, dynamic>>();
    return items.map((i) => i['sku'] as String).toList();
  } on TimeoutException {
    throw HttpException('request timed out');
  } on SocketException catch (e) {
    throw HttpException('network unreachable: ${e.message}');
  }
}

Future<void> post(http.Client client) async {
  final response = await client.post(
    Uri.https('api.example.com', '/v1/orders'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({'sku': 'ABC-1', 'quantity': 2}),
  );
  print(response.statusCode);
}

Future<void> main() async {
  // reuse one client: a new connection per request is slow and leaks sockets
  final client = http.Client();
  try {
    print(await fetchSkus(client));
  } finally {
    client.close();
  }
}

class TimeoutException implements Exception {
  final String message = 'timeout';
}

class HttpException implements Exception {
  HttpException(this.message);
  final String message;
  @override
  String toString() => 'HttpException: $message';
}
FailureTypical causeRetry?
SocketExceptionNo network, DNS failure, host downYes, with backoff
TimeoutExceptionSlow server or networkYes, once or twice
HandshakeExceptionTLS certificate problemNo: fix the certificate
Status 400 or 422The request body is wrongNo: fix the request
Status 401 or 403Missing or bad credentialsNo, until the token is refreshed
Status 429Rate limitedYes, after the delay the server asks for
Status 5xxServer faultYes, with backoff and a cap

Retry only idempotent requests by default. A POST that may have been processed before the connection dropped needs an idempotency key, or a retry creates a duplicate order.

FAQ

Should I use http or dio?
The http package is enough for straightforward requests and has fewer moving parts. A richer client such as dio adds interceptors, request cancellation, form data and progress callbacks, which pays off in an application with authentication and uploads.
Why does Dart complain about reading a file on the web?
dart:io is not available on the web platform. Keep file and socket code behind an interface with a conditional import so the same package compiles for the browser and for the VM.

Errors, exceptions and async error handling Command-line tools: args, stdin and exit codes

Last refreshed 2026-09-18.