---
url: >-
  https://thekevinscott.github.io/testing-conventions/explanation/one-function-per-file.md
description: >-
  Why a file holds one substantial function — and why the line threshold that
  decides "substantial" is yours to set.
---

# One function per file

`one-function-per-file` puts a ceiling on how much a single source file can hold: **one
module-scope function whose body runs longer than the configured threshold**. Trivial functions —
the ones at or under the threshold — sit together freely. This page explains why the standard
draws that line and why the threshold is configuration rather than a constant.

## Why the file is the unit

[Colocation](./colocated-test) pairs `widget.py` with `widget_test.py` and makes the unit boundary
structural. That pairing is worth exactly as much as the file is coherent. A file holding ten
functions still has one colocated test, so the 1:1 mapping the presence check enforces describes
the *file*, not the code in it: the test file's name announces `widget`, and a reader has to open
it to learn which of the ten functions it covers.

One function per file collapses that gap. The file name **is** the subject, so the colocated test
names its subject too, a rename moves both halves together, and the diff for a behavior change
touches the one function that changed. The rungs above presence sharpen the same way — a
[coverage](./coverage) or [mutation](./mutation) result reported against a file is a result about
one function, so a survivor points at the code that produced it.

The check also gives an agent a structural stopping point. "Add the helper here" is the cheapest
move in an agentic edit, and it is how a 40-line module becomes a 400-line one with a single
sprawling test beside it. A ceiling on the count makes the next function a new file.

## Why a threshold, and why it's yours

A one-line function is an expression with a name — a predicate, a formatter, a default. It carries
no branch to test in isolation, and giving it a file of its own is ceremony, not structure. So the
check counts only functions whose body runs longer than **`max_lines`**, and the default is `1`:
one line is trivial, anything longer earns its own module.

That default is the standard's opinion, not a law of the domain. A codebase whose natural grain is
small functions may want the line at three or five; one adopting the check onto an existing tree may
start at twenty and walk it down. `max_lines` is a plain number in the config, per language:

```toml
[python]
one_function_per_file = { max_lines = 5 }
```

Raising it never turns the check off — a file with two twenty-line functions fails at
`max_lines = 5` and at `max_lines = 19` alike. It moves only the boundary between "trivial enough
to share" and "substantial enough to own the file".

## What counts as a line

A function's length is the count of **code** lines in its body. Blank lines and comments don't
count, and neither does a Python docstring: documentation is the same documentation whether the
language spells it `#`, `//`, `///`, or a string on the first line, and it should never be what
pushes a function over the line.

The signature doesn't count either — only the body — so a function with six named parameters
across six lines is still a one-line function if its body is one line.

## What counts as a function

**Module-scope functions**, in each language's own spelling:

* **Python** — `def` and `async def` at module level.
* **TypeScript** — top-level `function` declarations, and a top-level `const` / `let` / `var` bound
  to an arrow function or function expression. `export` makes no difference either way.
* **Rust** — `fn` items at the top level of the file.

Methods are not module-scope functions: a Python class's methods, a TypeScript class's methods, and
a Rust `impl` block's methods all belong to the type that owns them, and the type is the file's one
subject. Nested functions and callbacks aren't module-scope either — a closure passed to `map`, a
handler defined inside another function, and a Rust `fn` declared inside a function body are all
part of their enclosing function's body, counted in its length rather than as functions of their
own.

## Rust is off until you opt in

The check runs for Python and TypeScript with no configuration. **For Rust it is off until a
`[rust].one_function_per_file` table names a threshold**, and an unconfigured Rust run says so:

```
unit one-function-per-file: not enabled for rust — set `[rust].one_function_per_file` to opt in
```

This is a deliberate asymmetry, and the reason is the languages differ in what a file *is*. In
Python and TypeScript a file is a bag of definitions with no meaning of its own, so "one subject per
file" is a choice the author makes and the check can hold them to. In Rust a file **is** a module —
the language's own unit of organization — and grouping a type, its `impl` blocks, and the free
functions that operate on it inside one module is how Rust is written, not a lapse. Enforcing the
check there by default would flag idiomatic code.

The capability is the same in all three languages: a Rust tree that wants the check names a
threshold and gets exactly what Python and TypeScript get. Only the default differs.

```toml
[rust]
one_function_per_file = { max_lines = 8 }
```

Unit tests are never subjects. The scan reads the same source tree
[colocated-test](./colocated-test) does, so Python and TypeScript test files, Rust's inline
`#[cfg(test)]` modules, and the suite directories beside the package are all outside it — a test
file is *supposed* to hold a function per case.

## When the check is wrong

A file whose functions genuinely belong together — a generated module, a table of small
constructors, a public surface re-exported as one API — takes a
[`one-function-per-file` exemption](/guide/configure#exempt-a-file) with a reason, like every other
deliberate omission in the standard.
