| |

Dart 45 🎯 File System I/O Operations (dart:io): Reading, Writing, and Streaming Files

The dart:io library is the bridge between a Dart program and the operating system. It provides the File class for reading and writing files, the Directory class for creating and listing directories, and the FileSystemEntity base class that both inherit from. The library is available on the Dart VM and in Flutter, but not in the browser, because the browser sandbox does not expose the file system. A Dart program that runs on a server, a command-line tool, or a mobile device uses dart:io for all its file operations.

The File class has two APIs for every operation: a synchronous API and an asynchronous API. The synchronous API is simpler and easier to read, but it blocks the event loop while the I/O is in progress. The asynchronous API returns a Future and does not block the event loop, which makes it the correct choice for a server or any program that must remain responsive. The synchronous methods are named without a suffix (readAsString, writeAsString), and the asynchronous methods are the same names returning futures. The choice between them is the choice between simplicity and responsiveness.

For large files, the whole-file methods are not appropriate. readAsString loads the entire file into memory, and writeAsString writes the entire string at once. A log file or a video file may be hundreds of megabytes, and loading it into memory is wasteful or impossible. The streaming API solves this: openRead returns a Stream<List<int>> of byte chunks, and openWrite returns an IOSink that accepts byte chunks. The streaming API processes the file incrementally, so the memory usage is bounded by the chunk size, not the file size.

This chapter covers three areas. First, why dart:io exists — the synchronous-versus-asynchronous distinction and the file-system abstraction. Second, how to read and write files — the whole-file methods, the streaming methods, and the encoding options. Third, how to work with directories and paths — creating, listing, and deleting directories, the path package, and the patterns for walking a directory tree. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the streaming model.

Key point: dart:io provides the File and Directory classes for file-system operations. The synchronous methods block the event loop; the asynchronous methods return futures and do not. Use the whole-file methods for small files and the streaming methods (openRead, openWrite) for large files. The path package provides platform-independent path manipulation.


Why dart:io exists

The synchronous-versus-asynchronous problem. A file operation takes time. The operating system must locate the file, read the bytes from the disk, and return them to the program. The time is measured in microseconds for a cached file and in milliseconds for a disk seek. If the program blocks while the operation is in progress, it cannot do anything else. In a command-line tool, blocking is acceptable: the program has nothing else to do. In a server, blocking is not acceptable: the server must handle other requests while the file is being read. The dart:io library provides both APIs, and the choice depends on the program’s concurrency requirements.

The file-system abstraction problem. The file system is a tree of directories and files, and the operations on it are: read a file, write a file, create a directory, list a directory, delete a file, rename a file, and check whether a file exists. The dart:io library provides a class for each of these: File for files, Directory for directories, and FileSystemEntity for the common operations. The classes are thin wrappers around the operating system’s system calls, and they provide a consistent API across Linux, macOS, and Windows.

The encoding problem. A file is a sequence of bytes, but a program usually works with strings. The conversion between bytes and strings is an encoding, and the default encoding is UTF-8. The dart:io library accepts an Encoding parameter on the read and write methods, and it provides the utf8, latin1, and ascii encodings. The utf8 encoding is the default, and it is the correct choice for text files that may contain non-ASCII characters. The latin1 and ascii encodings are faster but less general.

The streaming problem. The whole-file methods load the entire file into memory. For a small configuration file, this is fine. For a large log file or a video file, it is not. The streaming API reads the file in chunks and writes it in chunks, so the memory usage is bounded by the chunk size. The openRead method returns a Stream<List<int>>, and the openWrite method returns an IOSink. The stream and the sink are the two halves of the streaming model, and they compose with the stream operators.

The path problem. A file path is different on different platforms. Windows uses backslashes and drive letters; Linux and macOS use forward slashes. A path that is hardcoded with one separator breaks on the other platform. The path package provides functions for joining, normalizing, and splitting paths in a platform-independent way. The dart:io library uses the path package internally, and the recommendation is to use it explicitly for any path manipulation.

The trade-off. The synchronous API is simpler and easier to read. The asynchronous API is more complex and requires async/await or callbacks. The streaming API is more complex still, and it requires the stream operators or a manual loop. The trade-off is between the simplicity of the synchronous whole-file API and the responsiveness and memory efficiency of the asynchronous streaming API. For a command-line tool that reads a small file, the synchronous API is the right choice. For a server that processes a large file, the streaming API is the right choice.


a. Reading and writing whole files

The whole-file methods read or write the entire file at once. The methods are available in both synchronous and asynchronous forms.

Reading as a string. readAsString reads the file and decodes it using the specified encoding. The default encoding is UTF-8.

import 'dart:io';

void main() async {
  final file = File('config.txt');
  final contents = await file.readAsString();
  print(contents);
}

The synchronous version is readAsStringSync:

void main() {
  final file = File('config.txt');
  final contents = file.readAsStringSync();
  print(contents);
}

The asynchronous version returns a Future<String> and must be awaited. The synchronous version returns the string directly and blocks the event loop while the file is being read.

Reading as bytes. readAsBytes reads the file as a list of bytes, without decoding it. This is the correct choice for binary files.

final bytes = await File('image.png').readAsBytes();
print(bytes.length);

Reading as lines. readAsLines reads the file, decodes it, and splits it into lines. The line separator is the platform’s separator, and the trailing separator is removed.

final lines = await File('data.csv').readAsLines();
for (final line in lines) {
  print(line);
}

Writing a string. writeAsString writes a string to the file, encoding it with the specified encoding. The default mode is FileMode.write, which truncates the file if it exists.

final file = File('output.txt');
await file.writeAsString('Hello, world!\n');

The mode parameter controls the behavior. FileMode.write truncates the file. FileMode.append appends to the end. FileMode.writeOnly and FileMode.writeOnlyAppend are the byte-oriented equivalents.

await file.writeAsString('More text\n', mode: FileMode.append);

Writing bytes. writeAsBytes writes a list of bytes to the file.

final bytes = utf8.encode('Hello, world!');
await File('output.bin').writeAsBytes(bytes);

Checking existence and metadata. The exists, length, and lastModified methods return information about the file.

final file = File('config.txt');
if (await file.exists()) {
  final length = await file.length();
  final modified = await file.lastModified();
  print('$length bytes, modified $modified');
}

The synchronous versions are existsSync, lengthSync, and lastModifiedSync.


b. Streaming files

The streaming API processes the file in chunks. The openRead method returns a stream of byte chunks, and the openWrite method returns a sink for writing byte chunks.

Reading a stream. openRead returns a Stream<List<int>>. The optional parameters are start (the byte offset to start reading from) and end (the byte offset to stop reading).

final file = File('large.log');
final stream = file.openRead();

await for (final chunk in stream) {
  print('chunk: ${chunk.length} bytes');
}

Each chunk is a List<int> of bytes. The chunk size is determined by the operating system and the file system, and it is typically 64 KB. The stream can be transformed with the stream operators:

final lines = file
    .openRead()
    .transform(utf8.decoder)
    .transform(const LineSplitter());

await for (final line in lines) {
  print(line);
}

The utf8.decoder transformer converts the byte chunks into strings, and the LineSplitter transformer splits the strings into lines. The result is a stream of lines, and the file is processed incrementally.

Writing a stream. openWrite returns an IOSink. The sink accepts a list of bytes, a string, or another stream.

final file = File('output.txt');
final sink = file.openWrite();

sink.write('Hello, ');
sink.write('world!\n');
await sink.flush();
await sink.close();

The write method accepts a string and encodes it with the sink’s encoding. The add method accepts a list of bytes. The addStream method accepts a stream and pipes it to the sink.

final source = File('input.txt').openRead();
final sink = File('output.txt').openWrite();
await sink.addStream(source);
await sink.close();

The addStream method is the pattern for copying a file. The source stream is piped to the sink, and the bytes are copied without loading the entire file into memory.

The FileMode and the sink. The openWrite method accepts a mode parameter. FileMode.write truncates the file, and FileMode.append appends to the end.

final sink = File('log.txt').openWrite(mode: FileMode.append);

The pipe method. The pipe method on a stream writes the stream to an IOSink and closes the sink when the stream is done.

final source = File('input.txt').openRead();
final sink = File('output.txt').openWrite();
await source.pipe(sink);

The pipe method is the concise form of addStream followed by close.


c. Directories and paths

The Directory class represents a directory. It provides methods for creating, listing, and deleting directories.

Creating a directory. create creates the directory, and the recursive parameter creates the parent directories if they do not exist.

final dir = Directory('output/logs');
await dir.create(recursive: true);

Listing a directory. list returns a stream of FileSystemEntity objects. The recursive parameter lists the subdirectories.

final dir = Directory('lib');
await for (final entity in dir.list(recursive: true)) {
  if (entity is File) {
    print('file: ${entity.path}');
  } else if (entity is Directory) {
    print('dir: ${entity.path}');
  }
}

The listSync method returns a List<FileSystemEntity> instead of a stream.

Deleting a directory. delete deletes the directory. The recursive parameter deletes the contents.

await Directory('output').delete(recursive: true);

The path package. The path package provides functions for path manipulation. The join function joins path segments with the platform’s separator. The basename function returns the last segment. The dirname function returns the parent directory. The extension function returns the file extension.

import 'package:path/path.dart' as p;

final full = p.join('lib', 'src', 'main.dart');
print(full); // lib/src/main.dart (on Linux/macOS)

print(p.basename(full));  // main.dart
print(p.dirname(full));   // lib/src
print(p.extension(full)); // .dart

The path package is the correct way to manipulate paths. String concatenation with the separator character is not portable.

The Directory.systemTemp and Directory.current. Directory.systemTemp is the system’s temporary directory. Directory.current is the current working directory, and it can be set with Directory.current = path.

final temp = Directory.systemTemp.createTempSync('myapp_');
print(temp.path);

The createTemp method creates a temporary directory with a unique name.


Complete Example Session

// ============================================
// PART 1: READING A FILE AS A STRING
// ============================================

import 'dart:io';

void main() async {
  final file = File('config.txt');
  final contents = await file.readAsString();
  print(contents);
}


// ============================================
// PART 2: THE SYNCHRONOUS VERSION
// ============================================

void main() {
  final file = File('config.txt');
  final contents = file.readAsStringSync();
  print(contents);
}


// ============================================
// PART 3: READING AS LINES
// ============================================

void main() async {
  final file = File('data.csv');
  final lines = await file.readAsLines();

  for (final line in lines) {
    print(line);
  }
}


// ============================================
// PART 4: WRITING A STRING
// ============================================

void main() async {
  final file = File('output.txt');
  await file.writeAsString('Hello, world!\n');
}


// ============================================
// PART 5: APPENDING TO A FILE
// ============================================

void main() async {
  final file = File('log.txt');
  await file.writeAsString(
    'Log entry\n',
    mode: FileMode.append,
  );
}


// ============================================
// PART 6: CHECKING EXISTENCE AND METADATA
// ============================================

void main() async {
  final file = File('config.txt');

  if (await file.exists()) {
    final length = await file.length();
    final modified = await file.lastModified();
    print('$length bytes, modified $modified');
  } else {
    print('file not found');
  }
}


// ============================================
// PART 7: STREAMING A LARGE FILE
// ============================================

void main() async {
  final file = File('large.log');

  final lines = file
      .openRead()
      .transform(utf8.decoder)
      .transform(const LineSplitter());

  await for (final line in lines) {
    if (line.contains('ERROR')) {
      print(line);
    }
  }
}

// The file is processed incrementally.
// The memory usage is bounded by the chunk size.


// ============================================
// PART 8: COPYING A FILE
// ============================================

void main() async {
  final source = File('input.txt').openRead();
  final sink = File('output.txt').openWrite();

  await source.pipe(sink);
}

// The file is copied in chunks.
// The entire file is never loaded into memory.


// ============================================
// PART 9: LISTING A DIRECTORY RECURSIVELY
// ============================================

import 'package:path/path.dart' as p;

void main() async {
  final dir = Directory('lib');

  await for (final entity in dir.list(recursive: true)) {
    if (entity is File && p.extension(entity.path) == '.dart') {
      print(entity.path);
    }
  }
}

// Lists every .dart file in the lib directory
// and its subdirectories.


// ============================================
// PART 10: THE COMPLETE EXAMPLE
// ============================================

import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;

Future<void> processLogs(String directory) async {
  final dir = Directory(directory);
  if (!await dir.exists()) {
    print('Directory not found: $directory');
    return;
  }

  final errorFile = File(p.join(directory, 'errors.log'));
  final errorSink = errorFile.openWrite(mode: FileMode.write);

  try {
    await for (final entity in dir.list()) {
      if (entity is! File) continue;
      if (p.extension(entity.path) != '.log') continue;

      final lines = entity
          .openRead()
          .transform(utf8.decoder)
          .transform(const LineSplitter());

      await for (final line in lines) {
        if (line.contains('ERROR')) {
          errorSink.writeln('${p.basename(entity.path)}: $line');
        }
      }
    }
  } finally {
    await errorSink.flush();
    await errorSink.close();
  }

  final count = await errorFile.readAsLines();
  print('Found ${count.length} errors');
}

void main() async {
  await processLogs('logs');
}

The ten parts show reading a file as a string, the synchronous version, reading as lines, writing a string, appending, checking metadata, streaming a large file, copying a file, listing a directory recursively, and the complete example.


Quick Reference

Whole-File Methods

MethodReturnsEncoding
readAsString()Future<String>UTF-8 by default
readAsBytes()Future<Uint8List>No decoding
readAsLines()Future<List<String>>UTF-8 by default
writeAsString(s)Future<File>UTF-8 by default
writeAsBytes(b)Future<File>No encoding

Synchronous Variants

AsynchronousSynchronous
readAsString()readAsStringSync()
readAsBytes()readAsBytesSync()
readAsLines()readAsLinesSync()
writeAsString(s)writeAsStringSync(s)
writeAsBytes(b)writeAsBytesSync(b)
exists()existsSync()

Streaming

MethodReturns
openRead({start, end})Stream<List<int>>
openWrite({mode})IOSink
sink.add(bytes)Add bytes
sink.write(s)Add a string
sink.addStream(stream)Pipe a stream
source.pipe(sink)Copy a stream to a sink

File Modes

ModeBehavior
FileMode.writeTruncate and write
FileMode.appendAppend
FileMode.writeOnlyTruncate, byte-oriented
FileMode.writeOnlyAppendAppend, byte-oriented
FileMode.readRead only

Path Operations

FunctionPurpose
p.join(a, b)Join path segments
p.basename(path)Last segment
p.dirname(path)Parent directory
p.extension(path)File extension
p.normalize(path)Normalize the path

Best Practices

✅ Do This:

// Use the asynchronous API in servers
final contents = await File('config.txt').readAsString();                 // ✅
// Use the streaming API for large files
file.openRead().transform(utf8.decoder).transform(const LineSplitter());  // ✅
// Use the path package for path manipulation
p.join('lib', 'src', 'main.dart');                                        // ✅
// Use try/finally to close sinks
try { ... } finally { await sink.close(); }                               // ✅
// Check existence before reading
if (await file.exists()) { ... }                                          // ✅
// Use FileMode.append for logs
file.writeAsString(line, mode: FileMode.append);                          // ✅

❌ Don’t Do This:

// Don't use the synchronous API in a server
file.readAsStringSync(); // blocks the event loop                            // ❌
// Don't load a large file into memory
await File('huge.log').readAsString(); // may exhaust memory                 // ❌
// Don't concatenate paths with separators
'lib/' + 'src/main.dart'; // not portable                                   // ❌
// Don't forget to close the sink
sink.write('data'); // never closed                                         // ❌
// Don't ignore errors
File('missing.txt').readAsString(); // throws                                // ❌
// Don't use readAsString for binary files
await File('image.png').readAsString(); // corrupts                         // ❌

Common Pitfalls

PitfallWhy It HappensFix
FileSystemExceptionFile does not exist or permission deniedCheck exists() or catch the error
Memory exhaustionReading a large file with readAsStringUse the streaming API
Event loop blockedUsing the synchronous API in a serverUse the asynchronous API
Path not portableHardcoded separatorsUse the path package
Sink not closedNo close() callUse try/finally
Encoding errorsWrong encoding for the fileSpecify the encoding
Data lossFileMode.write truncatesUse FileMode.append

Real-World Examples

1. Read a String

final contents = await File('config.txt').readAsString();

2. Read Lines

final lines = await File('data.csv').readAsLines();

3. Write a String

await File('output.txt').writeAsString('Hello');

4. Append to a Log

await File('log.txt').writeAsString('entry\n', mode: FileMode.append);

5. Stream a File

file.openRead().transform(utf8.decoder).transform(const LineSplitter());

6. Copy a File

await File('in.txt').openRead().pipe(File('out.txt').openWrite());

7. List a Directory

await for (final entity in Directory('lib').list(recursive: true)) { }

8. Create a Directory

await Directory('output/logs').create(recursive: true);

9. Check Existence

if (await File('config.txt').exists()) { }

10. Join Paths

final path = p.join('lib', 'src', 'main.dart');

Visual

Synchronous vs Asynchronous I/O

┌──────────────────────────────────────────────────────────────┐
│  SYNCHRONOUS vs ASYNCHRONOUS I/O                             │
│                                                              │
│  SYNCHRONOUS:                                                │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  readAsStringSync()                                  │    │
│  │    │                                                 │    │
│  │    │  event loop BLOCKED                            │    │
│  │    │  (nothing else runs)                           │    │
│  │    ▼                                                 │    │
│  │  file read complete → return the string              │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  ASYNCHRONOUS:                                               │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  readAsString()                                      │    │
│  │    │                                                 │    │
│  │    │  event loop FREE                               │    │
│  │    │  (other work runs)                             │    │
│  │    ▼                                                 │    │
│  │  file read complete → the future completes           │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  Use synchronous for CLI tools.                              │
│  Use asynchronous for servers.                               │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Whole-File vs Streaming

┌──────────────────────────────────────────────────────────────┐
│  WHOLE-FILE vs STREAMING                                     │
│                                                              │
│  WHOLE-FILE:                                                 │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  readAsString()                                      │    │
│  │    │                                                 │    │
│  │    ▼                                                 │    │
│  │  [entire file in memory]                             │    │
│  │    │                                                 │    │
│  │    ▼                                                 │    │
│  │  process the string                                  │    │
│  │                                                       │    │
│  │  Memory: O(file size)                                │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  STREAMING:                                                  │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  openRead()                                          │    │
│  │    │                                                 │    │
│  │    ▼                                                 │    │
│  │  [chunk 1] → process → [chunk 2] → process → ...     │    │
│  │                                                       │    │
│  │  Memory: O(chunk size)                               │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  Use whole-file for small files.                             │
│  Use streaming for large files.                              │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Streaming Pipeline

┌──────────────────────────────────────────────────────────────┐
│  THE STREAMING PIPELINE                                      │
│                                                              │
│  File('large.log')                                           │
│    │                                                         │
│    ▼                                                         │
│  openRead()                                                  │
│    │  Stream<List<int>>                                      │
│    ▼                                                         │
│  .transform(utf8.decoder)                                    │
│    │  Stream<String>                                         │
│    ▼                                                         │
│  .transform(const LineSplitter())                            │
│    │  Stream<String> (lines)                                 │
│    ▼                                                         │
│  await for (final line in lines) { ... }                     │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  The file is read in chunks.                         │    │
│  │  Each chunk is decoded and split.                    │    │
│  │  Each line is processed as it arrives.               │    │
│  │  Memory usage is bounded by the chunk size.          │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Copying a File with pipe

┌──────────────────────────────────────────────────────────────┐
│  COPYING A FILE WITH pipe                                    │
│                                                              │
│  final source = File('input.txt').openRead();                │
│  final sink = File('output.txt').openWrite();                │
│  await source.pipe(sink);                                    │
│                                                              │
│  ┌──────────────┐         ┌──────────────┐                   │
│  │  input.txt   │         │  output.txt  │                   │
│  │              │         │              │                   │
│  │  chunk 1 ────┼────────►│  chunk 1     │                   │
│  │  chunk 2 ────┼────────►│  chunk 2     │                   │
│  │  chunk 3 ────┼────────►│  chunk 3     │                   │
│  │  ...         │         │  ...         │                   │
│  └──────────────┘         └──────────────┘                   │
│                                                              │
│  The source stream is piped to the sink.                     │
│  The sink is closed when the source is done.                 │
│  The entire file is never loaded into memory.                │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
dart:ioFile-system and I/O library
FileRead and write files
DirectoryCreate, list, delete directories
readAsString()Read a file as a string
readAsBytes()Read a file as bytes
writeAsString()Write a string
openRead()Stream a file
openWrite()Sink for writing
FileMode.appendAppend instead of truncate
path packagePlatform-independent path manipulation

Key takeaways:

  • dart:io provides the File and Directory classes. The File class reads and writes files, and the Directory class creates, lists, and deletes directories. Both provide synchronous and asynchronous APIs.
  • The synchronous API blocks the event loop; the asynchronous API does not. Use the synchronous methods in command-line tools, where blocking is acceptable. Use the asynchronous methods in servers, where the event loop must remain responsive.
  • The whole-file methods load the entire file into memory. readAsString, readAsBytes, and readAsLines are appropriate for small files. For large files, use the streaming API.
  • The streaming API processes the file in chunks. openRead returns a Stream<List<int>>, and openWrite returns an IOSink. The stream and the sink compose with the stream operators and the path package.
  • The FileMode controls the write behavior. FileMode.write truncates the file, and FileMode.append appends to the end. The mode is the difference between overwriting a log and adding to it.
  • The path package is the correct way to manipulate paths. String concatenation with a separator character is not portable. The path package handles the platform differences.
  • The pipe method copies a file in chunks. The source stream is piped to the sink, and the sink is closed when the source is done. The entire file is never loaded into memory.
  • Always close the sink and check for errors. A sink that is not closed may lose data. A file operation that is not wrapped in a try/catch may crash the program with a FileSystemException.

Remember: dart:io is the file-system library for Dart. The File class reads and writes files, and the Directory class works with directories. The synchronous API is simpler but blocks the event loop; the asynchronous API is more complex but keeps the program responsive. The whole-file methods are for small files; the streaming API is for large files. The path package handles the platform differences in path manipulation. The result is a complete file-system API that scales from a small configuration file to a large log-processing pipeline.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!