mrkeyoor.com_
Thu 06 Aug 08:49 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5async_open, AIOFile, Reader, Writer, and LineReader have kept their shape across the whole 3.x line and the only breaking change in recent memory is the Python floor moving to 3.11 in 3.12.0; the churn is in caio backends underneath rather than in the API you call
Docs3/5The README is unusually good for a small project, with runnable examples for every API, an explicit limitations section, and benchmark tables including the numbers where the library loses; there is no docs site, no API reference, and no changelog file, so version-to-version differences mean reading commits
Maintenance4/5Pushed 2026-08-04 with 3.12.3 the same day, 1 open issue out of 1 open issue and PR, and Python 3.14 already in the classifiers; it is a single maintainer who also owns caio and aiomisc, so the bus factor is one across the whole stack
Ecosystem3/5Around 17.6M downloads a week, mostly as a transitive dependency, but aiofiles is the name most tutorials and frameworks reach for and the two are not interchangeable in code; nothing in the wider async ecosystem accepts an AIOFile where it expects a file object

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
Skip it if

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 total

async 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

PackageRegistryPick it when
aiofilesPyPIYou want the exact builtin open() surface plus async os and tempfile wrappers, and you care more about portability than about kernel-level IO
anyioPyPIYou need file IO that runs unchanged on asyncio and trio, and an async Path object for metadata work
caioPyPIYou want the kernel AIO layer directly without the file-like wrappers, for example inside your own storage engine
aiomiscPyPIYou want this author's async file helpers as part of a broader service toolkit rather than as a standalone dependency