The dio package in Flutter: requests, errors, interceptors, downloading and uploading files
Egor Dolgov · 12.11.2026First, pick your system — the commands will match it.
dio is a package for HTTP requests in Dart and Flutter, just like
http
from
Lesson 7
. It can do more: shared options for all requests, interceptors, downloading and uploading files with progress. Both packages can cancel requests. In this article we look at what that gives you and how to use it.
Contents:
- When to pick dio and when http is enough
- Adding the package and running the examples
- The first request
- Errors: a 404 is already an exception
- Shared options: baseUrl and timeouts
- POST: sending JSON
- Interceptors: code before every request
- Cancelling a request
- Downloading a file with progress
- Uploading files to a server
- dio in a Flutter app
- http vs dio, point by point
- Common mistakes
- Summary
1. When to pick dio and when http is enough
The task: an app sends dozens of requests to one server. Each of them needs the same server address, an authorization token and a time limit, and you want to handle errors in one place. The
http
package has no shared options or interceptors — you would have to write them yourself. dio has them built in:
- shared options — the server address, timeouts and headers are set once for all requests;
- interceptors — code that runs before every request and after every response;
-
request cancellation
— for example, when the user has left the screen (
httphasAbortableRequestfor this); - downloading and uploading files with progress reports.
If you only have a couple of requests and need none of this,
http
from Lesson 7 is enough. The official Flutter guides use
http
too: for example,
Fetch data from the internet
. The Flutter documentation has no separate guide for dio.
dio is published by the CFUG team — the flutter.cn publisher on pub.dev; the source is on GitHub .
2. Adding the package and running the examples
In a Flutter project, run this in the project folder:
flutter pub add dio
pubspec.yaml
gets the line
dio: ^5.11.1
. In code, the package is imported like this:
import 'package:dio/dio.dart';
The examples in sections 3–10 are short console programs: that way you see the result of every request. They don’t need a separate Flutter project, and you already have the
dart
command —
the Flutter SDK includes the full Dart SDK
. Create a console project and add dio:
dart create dio_demo
cd dio_demo
dart pub add dio
Each example is a separate program: paste it into
bin/dio_demo.dart
in place of what is there and run it with
dart run
. The example in a Flutter app is in section 11.
3. The first request
The task: get post number 1 from a server. We request it from the JSONPlaceholder test server, as in Lesson 7:
import 'package:dio/dio.dart';
Future<void> main() async {
final dio = Dio();
final response = await dio.get(
'https://jsonplaceholder.typicode.com/posts/1',
);
print(response.statusCode);
print(response.data.runtimeType);
print(response.data['title']);
}
Result:
200
_Map<String, dynamic>
sunt aut facere repellat provident occaecati excepturi optio reprehenderit
-
Dio()is the client that requests go through. -
dio.get(address)is a GET request. The address is a plain string, unlikehttp, which needs aUri. -
response.datais the response already decoded from JSON : it is aMap, soresponse.data['title']works right away, withoutjsonDecode(). dio decodes the response when the server sends it with theapplication/jsontype.
4. Errors: a 404 is already an exception
This is the first difference you will notice after Lesson 7. There, a response with code 404 arrived as an ordinary response, and you checked the code yourself. By default dio treats only codes 200 to 299 as success (the
validateStatus
parameter changes that) and throws a
DioException
for any other:
import 'package:dio/dio.dart';
Future<void> load(Dio dio, String url) async {
try {
final response = await dio.get(url);
print('OK ${response.statusCode}');
} on DioException catch (e) {
print('${e.type}: ${e.response?.statusCode}');
}
}
Future<void> main() async {
final dio = Dio();
await load(dio, 'https://jsonplaceholder.typicode.com/posts/0');
await load(dio, 'https://no-such-host.invalid/posts/1');
}
Result:
DioExceptionType.badResponse: 404
DioExceptionType.connectionError: null
-
on DioException catch (e)catches any request error. -
e.typeis the kind of error.badResponse— the server replied with a code outside 200–299;connectionError— the server could not be reached (here, there is no such server). -
e.response?.statusCodeis the response code. When there is no response at all,e.responseisnull.
Other kinds of errors:
connectionTimeout
,
sendTimeout
,
receiveTimeout
— time ran out;
cancel
— the request was cancelled;
unknown
— some other error, with the original exception in
e.error
. A certificate error arrives this way, for example: for an expired or self-signed certificate
e.type
is
unknown
and
e.error
is a
HandshakeException
. The
badCertificate
kind only appears if you set up your own certificate check. The full list is in the
DioExceptionType
reference.
5. Shared options: baseUrl and timeouts
The task: stop repeating the server address and time limits in every request. They are set once in
BaseOptions
:
import 'package:dio/dio.dart';
final dio = Dio(
BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com',
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
),
);
Future<void> main() async {
final post = await dio.get('/posts/1');
final user = await dio.get('/users/1');
print(post.data['title']);
print(user.data['name']);
}
Result:
sunt aut facere repellat provident occaecati excepturi optio reprehenderit
Leanne Graham
-
baseUrlis the start of the address. The requestdio.get('/posts/1')goes tohttps://jsonplaceholder.typicode.com/posts/1. -
connectTimeoutis how long to wait for a connection to the server. -
receiveTimeoutis how long to wait for data: the first bytes of the response and every next chunk. It is not a limit on the whole response: a large file can take longer to download if the data keeps coming without pauses. - By default dio doesn’t limit the waiting time — a request can hang for a very long time. So it is worth setting timeouts.
-
Headers for all requests are set here too — the
headersparameter.
When the time runs out, dio throws a
DioException
of the matching kind. Let’s check with a deliberately tiny
receiveTimeout
:
import 'package:dio/dio.dart';
Future<void> main() async {
final dio = Dio(
BaseOptions(receiveTimeout: const Duration(milliseconds: 1)),
);
try {
await dio.get('https://jsonplaceholder.typicode.com/posts');
} on DioException catch (e) {
print(e.type);
}
}
Result:
DioExceptionType.receiveTimeout
.
The dio authors recommend keeping a single `Dio` instance in an app: then the options are the same for all requests. In the example above it is declared once at the top level of the file.
6. POST: sending JSON
The task: send a new post to the server.
import 'package:dio/dio.dart';
Future<void> main() async {
final dio = Dio(BaseOptions(baseUrl: 'https://jsonplaceholder.typicode.com'));
final response = await dio.post(
'/posts',
data: {'title': 'Hello', 'body': 'My first post', 'userId': 1},
);
print(response.statusCode);
print(response.requestOptions.contentType);
print(response.data);
}
Result:
201
application/json
{title: Hello, body: My first post, userId: 1, id: 101}
-
data:is the request body. dio turns aMapinto JSON by itself — nojsonEncode()needed. -
dio also sets the
Content-Type: application/jsonheader itself — you can see it in the second line of the result. - The reply is code 201 “created” and a post with number 101 (JSONPlaceholder saves nothing; it only replies as if it had).
7. Interceptors: code before every request
The task: see every request and response in the console, and add an authorization token to every request — for example, one received after signing in to the app. A constant header is easier to set in
BaseOptions
; here we need code that runs on every request. That is an interceptor: code that dio calls before every request and after every response.
import 'package:dio/dio.dart';
Future<void> main() async {
final dio = Dio(BaseOptions(baseUrl: 'https://jsonplaceholder.typicode.com'));
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
options.headers['Authorization'] = 'Bearer my-token';
print('→ ${options.method} ${options.uri}');
handler.next(options);
},
onResponse: (response, handler) {
print('← ${response.statusCode}');
handler.next(response);
},
),
);
await dio.get('/posts/1');
await dio.get('/users/1');
}
Result:
→ GET https://jsonplaceholder.typicode.com/posts/1
← 200
→ GET https://jsonplaceholder.typicode.com/users/1
← 200
-
onRequestis called before every request: here we add theAuthorizationheader and print the address. -
onResponse— after every response;onError— on every error. -
handler.next(...)passes the request or response on — to the next interceptor. Call it at the end of every handler, as in the example.
For a request log, dio has a ready-made
`LogInterceptor`
. By default it doesn’t print request and response bodies, and in Flutter the dio authors recommend printing its log with
debugPrint
:
LogInterceptor(logPrint: (o) => debugPrint(o.toString()))
.
8. Cancelling a request
The task: the user has left the screen, but the request is still running — its result is no longer needed. A request can be cancelled with a
CancelToken
:
import 'package:dio/dio.dart';
Future<void> main() async {
final dio = Dio();
final cancelToken = CancelToken();
final request = dio.get(
'https://jsonplaceholder.typicode.com/posts',
cancelToken: cancelToken,
);
cancelToken.cancel('The user left the screen');
try {
await request;
} on DioException catch (e) {
print('${e.type}: ${e.error}');
}
}
Result:
DioExceptionType.cancel: The user left the screen
.
-
cancelToken:is passed into the request. -
cancelToken.cancel(reason)cancels it: the request ends with aDioExceptionof kindcancel, ande.errorholds the reason. -
One
CancelTokencan cancel several requests at once.
9. Downloading a file with progress
The task: download a large file and show how much has been downloaded. That is what
dio.download
is for — it saves the response to a file and reports progress along the way:
import 'package:dio/dio.dart';
Future<void> main() async {
final dio = Dio();
await dio.download(
'https://jsonplaceholder.typicode.com/photos',
'photos.json',
onReceiveProgress: (received, total) {
if (total > 0) {
print('${(received / total * 100).toStringAsFixed(0)}%');
} else {
print('$received bytes');
}
},
);
print('Done');
}
Result (beginning and end):
670 bytes
9342 bytes
…
1039898 bytes
Done
-
The second argument is the path of the file to save to. In the console it is just a file name in the current folder; in an app the path is taken from the app’s folder — for example, with
getTemporaryDirectory()from the path_provider package, as in the `download` reference . -
onReceiveProgressis called as the download goes:receivedis how many bytes have arrived,totalis the full size. -
If the server didn’t report the size (the
content-lengthheader) or sent the data compressed (thecontent-encoding: gzipheader, as here),totalis −1. Then you can’t show percent, only the amount downloaded. -
On the web the browser doesn’t let you write files anywhere: there
downloaddownloads the file into memory and offers to save it, and the path becomes the suggested file name .
A large file and memory.
On a phone or a computer,
download
doesn’t keep the whole file in memory: it writes each chunk that arrives to disk and only then accepts the next one. In our test the program’s memory didn’t grow with the file size: downloading a 3 GB file, it was the same as when downloading with Dart’s own tools (
dart:io
). So download large files — videos, archives, updates — with
download
.
10. Uploading files to a server
The task: send a photo to a server — for example, to a profile or an album. To send a file together with form fields, the
multipart/form-data
format is used; in dio that is
FormData
.
This example needs two things:
-
a server that accepts files.
The code has the placeholder
https://example.com/uploadinstead of its address — put in your own server’s address. Which address and which field names it expects is in its documentation; -
a
photo.jpgpicture in thedio_demoproject folder, next topubspec.yaml: the file path is counted from the folder where you rundart run. If the file is missing, the program stops with aPathNotFoundException.
import 'package:dio/dio.dart';
const uploadUrl = 'https://example.com/upload';
Future<void> main() async {
final dio = Dio();
final formData = FormData.fromMap({
'title': 'Vacation',
'photo': await MultipartFile.fromFile('photo.jpg'),
});
final response = await dio.post(uploadUrl, data: formData);
print(response.statusCode);
print(response.data);
}
What comes back is up to the server. Our test server replied with code 200 and a list of what it received:
200
{fields: {title: Vacation}, files: [{field: photo, filename: photo.jpg, size: 150000}]}
-
FormData.fromMapbuilds the form: ordinary fields ('title') and files ('photo') together. -
MultipartFile.fromFile(path)is a file from disk. It is read in parts , not as a whole: in our test the program’s memory barely grew when uploading a 1 GB file. -
data: formData— the form goes as the body of the POST request; dio sets theContent-Typeheader itself. -
On the web there are no file paths, and
fromFilethrows anUnsupportedErrorthere. The file is passed as bytes — withMultipartFile.fromBytes.
Several files
go as a list in one field, and
onSendProgress
shows the upload progress:
import 'package:dio/dio.dart';
const uploadUrl = 'https://example.com/upload';
Future<void> main() async {
final dio = Dio();
final formData = FormData.fromMap({
'photos': [
await MultipartFile.fromFile('photo1.jpg'),
await MultipartFile.fromFile('photo2.jpg'),
],
});
final response = await dio.post(
uploadUrl,
data: formData,
onSendProgress: (sent, total) {
print('${(sent / total * 100).toStringAsFixed(0)}%');
},
);
print(response.data);
}
Result (beginning and end, the reply of our test server):
0%
13%
…
100%
{fields: {}, files: [{field: photos, filename: photo1.jpg, size: 200000}, {field: photos, filename: photo2.jpg, size: 300000}]}
-
In a list field every file goes under the same name —
photos, as the server’s reply shows. The dio README says the name becomesfiles[], but dio 5.11.1 sends it without brackets. -
If the server expects the name with brackets —
photos[], as is customary in PHP, for example — passListFormat.multiCompatibleas the second argument:FormData.fromMap({...}, ListFormat.multiCompatible). -
onSendProgressworks likeonReceiveProgressfrom section 9:sentis how many bytes have been sent,totalis how many in all. WithFormDatathe size is known in advance, so there is no need to check for −1 here. -
FormDataandMultipartFileare good for one request. To send again, create them anew, as the dio README advises, or make a copy withformData.clone().
11. dio in a Flutter app
Here is the
fetchPost
function from Lesson 7 rewritten with dio, together with a screen that loads the post when it opens. In the Lesson 7 project, run
flutter pub add dio
and replace
lib/main.dart
entirely. The network permissions from Lesson 7 (section 2) are needed here too.
import 'package:dio/dio.dart';
import 'package:material_ui/material_ui.dart';
final dio = Dio(
BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com',
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
),
);
class Post {
const Post({required this.id, required this.title, required this.body});
final int id;
final String title;
final String body;
factory Post.fromJson(Map<String, dynamic> json) {
return switch (json) {
{'id': int id, 'title': String title, 'body': String body} => Post(
id: id,
title: title,
body: body,
),
_ => throw const FormatException('Could not read the post'),
};
}
}
Future<Post> fetchPost(int id) async {
final response = await dio.get('/posts/$id');
return Post.fromJson(response.data as Map<String, dynamic>);
}
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('dio')),
body: const PostView(),
),
);
}
}
class PostView extends StatefulWidget {
const PostView({super.key});
@override
State<PostView> createState() => _PostViewState();
}
class _PostViewState extends State<PostView> {
late Future<Post> _post;
@override
void initState() {
super.initState();
_post = fetchPost(1);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: FutureBuilder<Post>(
future: _post,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final post = snapshot.data!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(post.title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(post.body),
],
);
},
),
);
}
}
What changed compared with Lesson 7:
-
the server address and timeouts are set once in
BaseOptions; -
there is no
jsonDecode()—response.datais already aMap; -
there is no
statusCodecheck: for a code outside 200–299 dio throws aDioExceptionitself, andFutureBuildershows “Error: …”.
12. http vs dio, point by point
-
A response with code 404 or 500.
http — an ordinary response, you check the code yourself. dio — a
DioExceptionof kindbadResponse. -
JSON in the response.
http —
jsonDecode(response.body). dio —response.datais already decoded. -
JSON in the request.
http —
jsonEncode()and theContent-Typeheader by hand. dio —data: {...}, the rest it does itself. -
Timeout.
http —
.timeout()on theFuture. dio —connectTimeout,receiveTimeout,sendTimeoutin the options. - Shared headers and logging. http — your own wrapper class around `Client` . dio — interceptors.
-
Cancellation.
http —
AbortableRequest(since version 1.5.0). dio —CancelToken. -
Downloading with progress.
Only dio has a ready-made method —
downloadwithonReceiveProgress. -
Uploading files.
http —
MultipartRequest. dio —FormDataindata:, progress withonSendProgress. - Who publishes it. http — the Dart team ( dart.dev ). dio — CFUG ( flutter.cn ).
13. Common mistakes
-
DioErroris deprecated. Since dio 5.2.0 the error class is calledDioException;DioErroris kept as deprecated. In old examples from the internet, replaceDioErrorwithDioException. -
Expecting a 404 in the response and getting an exception.
If you are used to
http, catch a response with an error code inon DioException catch (e)and look ate.response?.statusCode. -
A new
Dio()for every request. Then you have to set the options and interceptors again each time. Create one instance for the app. -
A request hangs for a very long time.
Timeouts are not set by default — set them in
BaseOptions.
A certificate error (a
HandshakeException
inside a
DioException
) and how to trust your own certificate — in a separate article about HTTPS and certificates (coming soon).
14. Summary
-
dio is an HTTP client with shared options (
BaseOptions), interceptors, cancellation (CancelToken), downloading (download) and uploading files (FormData). -
On a phone or a computer,
downloadwrites large files to disk in chunks andMultipartFile.fromFilereads them in parts — the whole file never sits in memory. -
response.datais already decoded from JSON, and aMapindata:is sent as JSON. -
By default a response with a code outside 200–299 is a
DioException; the kind of error is ine.type. - Timeouts are not set by default — set them yourself.
-
For a couple of simple requests the
httppackage is enough — Lesson 7 .
Send questions and suggestions about the article to contact@flutter.su.