aiofiles review
aiofiles 25.1.0 gives asyncio code an awaitable version of Python's familiar file interface. Calls such as read, write, seek, flush, and close still use normal blocking file IO; aiofiles moves each call to an executor so it does not occupy the event-loop thread. It also covers temporary files, standard streams, and a named subset of os and os.path functions. The current release adds Python 3.14 support, adopts uv for the project, and removes Python 3.8 support. Our installed wheel was pure Python and had no py.typed marker.
aiofiles 25.1.0 installed in 0.2 seconds and occupied 1 MB in our sandbox, with no dependencies or audit findings, so it is a cheap fix for blocking local-file calls inside an asyncio service. Leave it out of one-time startup reads, Trio code, and projects that require a py.typed marker.
We installed it
| Install | ✓ · 0.2s | 1 package on disk · 1 MB |
| Import | ✓ | import aiofiles in 0.28s · pure Python · requires Python >=3.9 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does aiofiles install cleanly?
Yes. In a fresh container with an empty cache, pip install aiofiles finished in 0.2s, leaving 1 package and 1 MB on disk. pip-audit reported no known vulnerabilities.
What does aiofiles need to run?
Python >=3.9, and nothing compiled: it is pure Python. In our run import aiofiles succeeded in 0.28s.
aiofiles or anyio: which should you use?
Pick anyio when file helpers must follow either asyncio or Trio through one portability layer. aiofiles 25.1.0 installed in 0.2 seconds and occupied 1 MB in our sandbox, with no dependencies or audit findings, so it is a cheap fix for blocking local-file calls inside an asyncio service.
When should you not use aiofiles?
You need true kernel asynchronous file IO or expect one file operation to become faster. The README says aiofiles delegates blocking calls to threads.
Use it if
- An asyncio server occasionally reads or writes local files and those blocking calls are stalling unrelated requests.
- Your team wants async with and await while keeping method names close to built-in file objects.
- Temporary files or a documented subset of os operations must run through the same asyncio executor pattern.
- You can cap file concurrency or assign a separate executor when disk work becomes sustained.
- You need true kernel asynchronous file IO or expect one file operation to become faster. The README says aiofiles delegates blocking calls to threads.
- The code must run unchanged on Trio. aiofiles targets asyncio; AnyIO has file helpers that follow its supported async backends.
- Your typing policy requires installed packages to advertise inline types. Our 25.1.0 wheel did not contain py.typed.
- You need an awaitable equivalent of every pathlib or os method. aiofiles documents a fixed list of wrappers rather than a complete async standard library.
- The process reads a small configuration file once before starting its event loop. Built-in open is simpler and avoids scheduling work on an executor.
Setup reality
We installed aiofiles 25.1.0 in a clean Python 3.12 Bookworm container. Installation took 0.2 seconds and left one package using 1 MB. It has zero direct dependencies, is pure Python, and requires Python 3.9 or later. pip-audit found zero known vulnerabilities. import aiofiles succeeded in 0.28 seconds, while our package inspection found no py.typed marker.
No service account, environment variable, or config file is involved. aiofiles.open() accepts the usual path, mode, buffering, and encoding arguments, plus optional loop and executor values. With no executor supplied, version 25.1.0 uses the event loop's default executor. A busy file workload can therefore compete with other blocking functions that the application sends to that same pool.
Every delegated operation needs await, including read, write, flush, seek, and close. An async with block is the safest ownership boundary because it closes the file when control leaves the block. Async line iteration limits memory use, though each read still crosses to a worker thread. Reading useful chunks usually creates less executor traffic than issuing many tiny reads.
aiofiles.os and aiofiles.tempfile are separate imports. The README lists the supported os calls, including stat, sendfile, replace, remove, mkdir, and selected path checks. Those wrappers keep the underlying platform rules: a cross-filesystem replace can fail, and some calls are unavailable on some systems. Release 25.1.0 supports Python 3.14 and drops 3.8; Python 3.8 applications must stay on 24.1.0.
Patterns
Read one text file read-text
import aiofiles
async def load_note(path: str) -> str:
async with aiofiles.open(path, 'r', encoding='utf-8') as file:
return await file.read()Both the context manager and read are asynchronous in 25.1.0; omitting await returns a coroutine instead of file contents.
Write and flush text write-text
import aiofiles
async def save_note(path: str, text: str) -> None:
async with aiofiles.open(path, 'w', encoding='utf-8') as file:
await file.write(text)
await file.flush()`write()` and `flush()` each enter the executor. Exiting the async context closes the handle.
Filter a file line by line stream-lines
import aiofiles
async def failed_rows(path: str):
async with aiofiles.open(path, encoding='utf-8') as file:
async for line in file:
if line.startswith('FAIL,'):
yield line.rstrip('\n')Async iteration avoids loading the complete file, but aiofiles still performs the reads on an executor thread.
Yield bounded binary chunks read-chunks
import aiofiles
async def read_blocks(path: str, block_size: int = 1024 * 1024):
async with aiofiles.open(path, 'rb') as file:
while block := await file.read(block_size):
yield blockThe 1 MiB value is an application choice, not a package default. Very small blocks create more executor handoffs.
Copy with bounded memory copy-binary-file
import aiofiles
async def copy_file(source: str, target: str) -> None:
async with aiofiles.open(source, 'rb') as src:
async with aiofiles.open(target, 'wb') as dst:
while chunk := await src.read(1024 * 1024):
await dst.write(chunk)This controls memory use, although reads and writes remain blocking OS operations delegated to threads.
Read bytes from an offset read-range
import aiofiles
async def read_range(path: str, offset: int, count: int) -> bytes:
async with aiofiles.open(path, 'rb') as file:
await file.seek(offset)
return await file.read(count)Seek and read are two separately delegated calls in the documented 25.1.0 interface.
Round-trip through a temporary file temporary-file
import aiofiles.tempfile
async def stage(data: bytes) -> bytes:
async with aiofiles.tempfile.TemporaryFile('wb+') as file:
await file.write(data)
await file.seek(0)
return await file.read()Temporary-file methods use await just like handles returned by aiofiles.open().
Create a file under a temporary directory temporary-directory
from pathlib import Path
import aiofiles
import aiofiles.tempfile
async def render_preview() -> bytes:
async with aiofiles.tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'preview.bin'
async with aiofiles.open(path, 'wb') as file:
await file.write(b'preview')
async with aiofiles.open(path, 'rb') as file:
return await file.read()The directory value is an ordinary path. Calling synchronous pathlib IO on it can still block the event-loop thread.
Inspect a path without blocking the loop stat-path
import aiofiles.os
async def file_size(path: str) -> int | None:
if not await aiofiles.os.path.isfile(path):
return None
result = await aiofiles.os.stat(path)
return result.st_sizeImport `aiofiles.os` explicitly. Only the os and os.path functions named in the README have wrappers.
Replace a completed file atomic-replace
import aiofiles
import aiofiles.os
async def replace_text(temp_path: str, final_path: str, text: str) -> None:
async with aiofiles.open(temp_path, 'w', encoding='utf-8') as file:
await file.write(text)
await file.flush()
await aiofiles.os.replace(temp_path, final_path)`os.replace` keeps native filesystem semantics. It can fail when source and destination are on different filesystems.
Limit simultaneous file reads bound-file-concurrency
import asyncio
import aiofiles
async def read_one(path: str, slots: asyncio.Semaphore) -> str:
async with slots:
async with aiofiles.open(path, encoding='utf-8') as file:
return await file.read()
async def read_all(paths: list[str]) -> list[str]:
slots = asyncio.Semaphore(8)
return await asyncio.gather(*(read_one(path, slots) for path in paths))The limit of 8 is an example. Measure the storage and executor instead of opening an unbounded list of files.
Give file work its own thread pool use-private-executor
from concurrent.futures import ThreadPoolExecutor
import aiofiles
file_workers = ThreadPoolExecutor(max_workers=4, thread_name_prefix='file-io')
async def load_report(path: str) -> str:
async with aiofiles.open(path, encoding='utf-8', executor=file_workers) as file:
return await file.read()Shut down the executor during application teardown. A private pool keeps these calls out of the loop's default executor.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| anyio | PyPI | Pick it when file helpers must follow either asyncio or Trio through one portability layer. |
| aiofile | PyPI | Consider it for its lower-level file API when you have checked its platform-specific implementation and fallback. |
| aiopath | PyPI | Use it when an asynchronous pathlib-style interface is more useful than a close copy of open(). |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

