Local Data Storage

File Storage with path_provider

15 min Lesson 4 of 12

File Storage with path_provider

Mobile apps frequently need to persist data beyond a single session — configuration files, cached API responses, downloaded media, or user-generated content. Flutter's dart:io library provides the raw File API, but you first need to know where to write. Hard-coding a path like /data/user/0/... breaks on iOS and fails across Android versions. The path_provider package solves this by exposing platform-correct directory accessors that work on Android, iOS, macOS, Windows, and Linux.

Note: path_provider is not part of the Flutter SDK. Add it to pubspec.yaml before importing it. As of 2024, the canonical package is path_provider: ^2.1.0 published by the Flutter team on pub.dev.

Adding the Dependency

Open pubspec.yaml and add the package under dependencies:

dependencies:
  flutter:
    sdk: flutter
  path_provider: ^2.1.0

Then run flutter pub get to fetch it.

The Three Key Directories

path_provider exposes several directory getters. The three you will use in almost every app are:

  • getApplicationDocumentsDirectory() — Persistent, user-facing storage. Files survive app updates and are backed up by iCloud / Google Backup on the respective platforms. Use this for documents the user explicitly creates or saves.
  • getApplicationCacheDirectory() — Persistent but clearable storage. The OS may evict these files when disk space is low. Use this for downloaded thumbnails, cached API JSON, or other data you can re-fetch.
  • getTemporaryDirectory() — Transient scratch space. Files here are not guaranteed to survive between app launches and are regularly cleaned by the OS. Use this for in-progress downloads, extracted archives, or intermediate processing buffers.
Tip: Always prefer getApplicationDocumentsDirectory() for data you cannot afford to lose (user preferences, offline content). Reserve getApplicationCacheDirectory() for data you can reconstruct, and getTemporaryDirectory() for throwaway buffers.

Reading and Writing Text Files

Once you have a directory, construct a File path with path.join() from the path package (or use p.join), then call the async read/write methods on dart:io File:

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

/// Returns the path to a file called [filename] inside the documents directory.
Future<File> _localFile(String filename) async {
  final dir = await getApplicationDocumentsDirectory();
  return File(p.join(dir.path, filename));
}

/// Writes [content] to notes.txt, replacing any existing content.
Future<void> writeNote(String content) async {
  final file = await _localFile('notes.txt');
  await file.writeAsString(content);
}

/// Reads notes.txt and returns its content, or an empty string if missing.
Future<String> readNote() async {
  try {
    final file = await _localFile('notes.txt');
    return await file.readAsString();
  } on PathNotFoundException {
    return '';          // File does not exist yet
  } catch (e) {
    return '';          // Handle other I/O errors gracefully
  }
}

Key points about the example above:

  • All file operations in dart:io are asynchronous (Future-based). Always await them or chain with .then().
  • writeAsString creates the file if it does not exist and overwrites it if it does. Pass mode: FileMode.append to append instead.
  • Wrap reads in a try/catch: a PathNotFoundException is thrown on the first run before the file has been created.

Reading and Writing Binary Files

For images, PDFs, or serialised data, use readAsBytes() and writeAsBytes() which work with Uint8List:

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

/// Caches [bytes] (e.g. image data) to the cache directory.
Future<File> cacheImage(String filename, Uint8List bytes) async {
  final cacheDir = await getApplicationCacheDirectory();
  final file = File(p.join(cacheDir.path, filename));
  return file.writeAsBytes(bytes);
}

/// Loads a cached image, returning null if it does not exist.
Future<Uint8List?> loadCachedImage(String filename) async {
  try {
    final cacheDir = await getApplicationCacheDirectory();
    final file = File(p.join(cacheDir.path, filename));
    if (await file.exists()) {
      return await file.readAsBytes();
    }
    return null;
  } catch (e) {
    return null;
  }
}

Async Error Handling Patterns

File I/O can fail for many reasons: permissions denied, disk full, path not found, or concurrent access. Flutter best practice is to handle errors explicitly rather than let exceptions propagate to the UI:

  • Use try/catch with specific exception types (PathNotFoundException, FileSystemException) before a generic catch-all.
  • Check await file.exists() before reading if you prefer conditional logic over exceptions.
  • Log errors to your analytics or debug console rather than silently swallowing them.
  • Surface user-friendly messages in the UI instead of raw exception strings.
Warning: Never call file I/O synchronously (e.g. file.readAsStringSync()) on the main isolate in a Flutter app. Blocking the main thread causes jank and dropped frames. Always use the async/await versions.

Putting It All Together in a Widget

A typical pattern is to load persisted data in initState and save it when the user interacts with the app:

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

class NotepadScreen extends StatefulWidget {
  const NotepadScreen({super.key});

  @override
  State<NotepadScreen> createState() => _NotepadScreenState();
}

class _NotepadScreenState extends State<NotepadScreen> {
  final _controller = TextEditingController();
  bool _loading = true;

  @override
  void initState() {
    super.initState();
    _loadNote();
  }

  Future<File> get _noteFile async {
    final dir = await getApplicationDocumentsDirectory();
    return File(p.join(dir.path, 'note.txt'));
  }

  Future<void> _loadNote() async {
    try {
      final file = await _noteFile;
      if (await file.exists()) {
        final text = await file.readAsString();
        _controller.text = text;
      }
    } on FileSystemException catch (e) {
      debugPrint('Could not load note: $e');
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  Future<void> _saveNote() async {
    try {
      final file = await _noteFile;
      await file.writeAsString(_controller.text);
      if (mounted) {
        ScaffoldMessenger.of(context)
            .showSnackBar(const SnackBar(content: Text('Saved!')));
      }
    } on FileSystemException catch (e) {
      debugPrint('Could not save note: $e');
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_loading) return const Center(child: CircularProgressIndicator());
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Note'),
        actions: [
          IconButton(icon: const Icon(Icons.save), onPressed: _saveNote),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: TextField(
          controller: _controller,
          maxLines: null,
          expands: true,
          decoration: const InputDecoration(
            hintText: 'Start typing...',
            border: InputBorder.none,
          ),
        ),
      ),
    );
  }
}

Summary

The path_provider package is the standard way to obtain platform-safe directory paths in Flutter. Use getApplicationDocumentsDirectory() for permanent user data, getApplicationCacheDirectory() for re-creatable cached data, and getTemporaryDirectory() for transient scratch files. Combine these paths with dart:io File to read and write text or binary data asynchronously, always wrapping operations in try/catch to handle filesystem errors gracefully and keep your UI responsive.

Key Takeaway: Never hard-code file paths in a Flutter app. Always obtain them at runtime from path_provider, and always perform I/O with async/await to avoid blocking the UI thread.