File Storage with path_provider
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.
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.
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:ioare asynchronous (Future-based). Alwaysawaitthem or chain with.then(). writeAsStringcreates the file if it does not exist and overwrites it if it does. Passmode: FileMode.appendto append instead.- Wrap reads in a
try/catch: aPathNotFoundExceptionis 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/catchwith 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.
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.
async/await to avoid blocking the UI thread.