aiofile
aiofile does asynchronous file IO by talking to the kernel rather than by hiding a thread pool. It sits on caio, which picks the best backend your system offers: Linux io_uring, classic Linux libaio, a C thread pool for macOS, or a pure Python thread fallback anywhere else. The high-level entry point is async_open(path, mode), which gives you something that behaves like a normal file object with awaitable read, write, and readline, plus async iteration over lines and iter_chunked() for blocks. Underneath sits AIOFile, which deliberately has no file pointer at all: every read and write takes an explicit offset, so many operations can be in flight against one descriptor without a lock serialising them. That positional design is the actual difference from thread-offload libraries, and it is why concurrent reads of one file get faster here instead of slower.
The right pick when you are on Linux and file IO is actually your bottleneck, because positional reads against one descriptor scale where a shared seek cursor does not. Everywhere else, especially Windows or code that also needs async stat and listdir, aiofiles is the simpler answer.
Use it if
- You are on Linux and doing enough file IO that the difference between a real kernel submit and a thread hop matters, for example a log shipper, a media server, or a batch processor
- You need many concurrent operations against one open file: AIOFile takes an offset per call, so eight parallel reads do not queue behind one shared seek cursor the way they do with aiofiles
- You want to hash, parse, or transcode one large file several ways at once: clone() gives you extra file-like views over a single descriptor, each with its own offset
- You want the backend to be your decision, not a guess: CAIO_IMPL selects uring, linux, thread, or python at runtime, and you can pass an explicit caio context per file
- You want types out of the box: the wheel ships py.typed, so mypy and pyright see real signatures with no separate stubs package
- You are on Windows. caio 0.12.2 publishes no Windows wheels, so pip installs the pure Python fallback and you get a thread pool with extra layers, which is strictly worse than aiofiles for that platform
- You need async filesystem metadata operations. There is no equivalent of aiofiles.os here: stat, listdir, makedirs, rename, and unlink all stay synchronous, and there is no async tempfile module either
- Your files are small or few. The kernel AIO path is a real win at scale, but for a handful of config files a plain open() beats any of this, and the extra dependency buys nothing
- You need Python 3.10 or older. Since 3.12.0 the floor is Python 3.11, so older runtimes are stuck on the 3.11.x line
- You are on trio or want runtime portability. This is asyncio only, and anyio's file API works across both event loops with a familiar Path object
- Your files live on procfs, sysfs, or a pipe. The Linux io_uring and libaio backends cannot open special files at all, so those paths need an explicitly constructed thread context passed in
- You want a large maintainer bench. One maintainer, 582 stars, and four releases went out on 2026-08-04 alone, which is responsive but also means patch releases land without much soak time
Setup reality
pip install aiofile is a pure Python wheel that pulls caio~=0.12.0, and caio is where the C code lives. Wheels exist for manylinux and musllinux on x86_64 and aarch64 and for macOS, plus a py3-none-any fallback, so Windows and anything unusual quietly land on the pure Python thread backend with no warning printed. Python 3.11 is the floor since 3.12.0. The real setup work is choosing a backend and confirming which one you got, because the same code has very different performance on io_uring versus a thread pool: set CAIO_IMPL to uring, linux, thread, or python before the process starts, and remember io_uring needs kernel 5.1 or newer. Then the API shape catches people. async_open returns an awaitable wrapper, so you need async with or an await, and seek() and tell() on it are ordinary synchronous methods while read, write, readline, flush, and close are coroutines. Drop to AIOFile and there is no file pointer at all: every read and write takes an offset argument that defaults to 0, so a loop of writes with no offset silently overwrites byte zero every time. Containers add one more trap, since a build machine with io_uring available can produce an image that runs on a host where the syscall is blocked by seccomp, and the failure surfaces at open time.
Patterns
Read and write with the file-like APIread-write-file
import asyncio
from aiofile import async_open
async def main():
async with async_open("report.txt", "w+") as afp:
await afp.write("Hello ")
await afp.write("world")
afp.seek(0)
print(await afp.read())
asyncio.run(main())seek() and tell() are plain synchronous methods here while read, write, readline, flush, and close are coroutines, so awaiting seek is a TypeError and forgetting to await write leaves you with an un-run coroutine. Without async with you must await the open call yourself and close it by hand.
Stream a text file line by lineiterate-lines
from aiofile import async_open
async def count_errors(path):
total = 0
async with async_open(path, "r") as afp:
async for line in afp:
if "ERROR" in line:
total += 1
return totalasync for goes through LineReader, which keeps a roughly 4 KB buffer and finds line boundaries inside it. Calling await afp.readline() in a loop instead is much slower for short lines, because it does not reuse that buffer; the README says so explicitly.
Copy a large file in fixed chunkschunked-copy
from aiofile import async_open
async def copy(src, dst, chunk_size=65536):
async with async_open(src, "rb") as fin, async_open(dst, "wb") as fout:
async for chunk in fin.iter_chunked(chunk_size):
await fout.write(chunk)iter_chunked defaults to 32 KB. Chunk size is the knob that matters: too small and per-operation overhead dominates whichever backend you are on, too large and you hold the whole chunk in memory per concurrent copy. Binary mode is required for a byte-exact copy, since text mode round trips through the encoding.
The low-level AIOFile with explicit offsetspositional-io
import asyncio
from aiofile import AIOFile
async def main():
async with AIOFile("hello.txt", "w+") as afp:
payload = "Hello world\n"
await asyncio.gather(*[
afp.write(payload, offset=i * len(payload)) for i in range(10)
])
await afp.fsync()
assert await afp.read(len(payload) * 10) == payload * 10
asyncio.run(main())AIOFile has no internal file pointer at all, so offset is not optional in practice even though it defaults to 0. A loop of await afp.write(chunk) with no offset writes every chunk over the top of byte zero and leaves you with only the last one. This positional model is why ten concurrent writes here do not serialise.
Sequential access without tracking offsets yourselfreader-writer-helpers
from aiofile import AIOFile, Reader, Writer, LineReader
async def main():
async with AIOFile("/tmp/data.txt", "w+") as afp:
write = Writer(afp)
await write("first\n")
await write("second\n")
await afp.fsync()
async for chunk in Reader(afp, chunk_size=8192):
handle(chunk)
async for line in LineReader(afp, chunk_size=4096, line_sep="\n"):
handle(line)Reader, Writer, and LineReader each carry their own offset and their own lock, which is the layer that turns positional AIOFile back into sequential access. They all take offset=0 as a starting point, so you can hand different workers different regions of the same file.
Read one file several ways at onceclone-parallel-readers
import asyncio, hashlib
import aiofile
async def digest(name, hasher, afp):
loop = asyncio.get_running_loop()
async for chunk in afp.iter_chunked(1 << 20):
await loop.run_in_executor(None, hasher.update, chunk)
return name, hasher.hexdigest()
async def main(path):
async with aiofile.async_open(path, "rb") as source:
jobs = [("sha256", hashlib.sha256()), ("sha512", hashlib.sha512())]
return await asyncio.gather(*[
digest(n, h, await aiofile.clone(source)) for n, h in jobs
])clone() returns an object you can await or use as an async context manager; it duplicates the descriptor so each reader gets an independent offset without opening the file again. Note the hashing itself is CPU work pushed to an executor, because doing it inline would block the loop the file IO just freed up.
Pick the caio backend deliberatelychoose-backend
# at process start, before importing aiofile
# CAIO_IMPL=uring Linux io_uring, needs kernel 5.1+
# CAIO_IMPL=linux classic libaio
# CAIO_IMPL=thread C thread pool (macOS, portable)
# CAIO_IMPL=python pure Python thread fallback
import os
os.environ.setdefault("CAIO_IMPL", "linux")
from caio import linux_aio_asyncio
from aiofile import async_open
async def main():
ctx = linux_aio_asyncio.AsyncioContext()
async with async_open("/var/log/app.log", "r", context=ctx) as afp:
print(await afp.read())The default is autodetected, so identical code can run on io_uring in CI and a thread pool in production without saying so. Set CAIO_IMPL explicitly in anything you care about, and be aware io_uring is often blocked by container seccomp profiles even when the kernel supports it.
Read procfs, sysfs, and pipesspecial-files
import asyncio
from contextlib import AsyncExitStack
from aiofile import async_open
from caio import thread_aio_asyncio
async def main():
async with AsyncExitStack() as stack:
ctx = await stack.enter_async_context(thread_aio_asyncio.AsyncioContext())
src = await stack.enter_async_context(
async_open("/proc/cpuinfo", "r", context=ctx)
)
dest = await stack.enter_async_context(async_open("/tmp/cpuinfo", "w"))
async for line in src:
await dest.write(line)
asyncio.run(main())The kernel AIO backends cannot open special files, so this is a limitation of io_uring and libaio rather than a bug in aiofile. Construct one thread context and reuse it for every special path, since a fresh context per file is expensive and the count of concurrent operations per context is bounded.
Adopt a file object you already openedwrap-existing-handle
import asyncio
from aiofile import async_open
async def append(fp):
async with async_open(fp) as afp:
await afp.write("line from async world\n")
await afp.flush()
with open("test.txt", "w+") as fp:
asyncio.run(append(fp))Passing an open file object instead of a path is how you handle a descriptor that came from tempfile, a socket pair, or a caller. Mode arguments are rejected in that form because the mode is already fixed by the open, and the outer with block still owns closing it.
Process many files without exhausting descriptorsbounded-concurrency
import asyncio
from aiofile import async_open
async def read_one(path, sem):
async with sem:
async with async_open(path, "rb") as afp:
return path, await afp.read()
async def read_all(paths, limit=32):
sem = asyncio.Semaphore(limit)
return await asyncio.gather(*(read_one(p, sem) for p in paths))Kernel AIO removes the thread pool ceiling, not the file descriptor limit, so gather over ten thousand paths still hits RLIMIT_NOFILE. A caio context also has a bounded number of in-flight operations, so concurrency past that point just queues while holding every descriptor open.
Make sure bytes actually reach the diskdurable-writes
from aiofile import AIOFile, async_open
async def write_durable(path, payload: bytes):
async with AIOFile(path, "wb") as afp:
await afp.write_bytes(payload, offset=0)
await afp.fsync() # data plus metadata
# await afp.fdsync() # data only, cheaper
async def truncate_log(path):
async with async_open(path, "r+") as afp:
await afp.flush(sync_metadata=True)
await afp.file.truncate(0)Leaving the context manager closes the file but does not promise the data is on stable storage; only fsync or fdsync does that, and fdsync skips the metadata update so it is the cheaper choice for appends to an existing file. The wrapper's flush takes sync_metadata, and .file gets you the AIOFile underneath for truncate and fileno.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| aiofiles | PyPI | You want the exact builtin open() surface plus async os and tempfile wrappers, and you care more about portability than about kernel-level IO |
| anyio | PyPI | You need file IO that runs unchanged on asyncio and trio, and an async Path object for metadata work |
| caio | PyPI | You want the kernel AIO layer directly without the file-like wrappers, for example inside your own storage engine |
| aiomisc | PyPI | You want this author's async file helpers as part of a broader service toolkit rather than as a standalone dependency |