Polyatic
All tools

CODEOWNERS Explained

A CODEOWNERS file is a plain-text list of path patterns and the people or teams responsible for them. GitHub reads it when a pull request opens and uses it to request reviews automatically — and, if the branch is protected accordingly, to block the merge until an owner approves. The syntax looks like .gitignore, which is where the trouble starts: it borrows most of gitignore's pattern rules, drops three of them, and resolves conflicts with a precedence rule that is the opposite of what most people assume. Everything below is checked against GitHub's own “About code owners” documentation, which is the normative source: where this page and that page ever disagree, GitHub's documentation wins and this one is wrong.

Five stacked rule bars on a dark indigo background with only the bottom bar lit, illustrating that the last matching rule in a CODEOWNERS file is the one that decides the owner.
Several rules can match one path. Only the last of them decides who reviews the change.

Where GitHub looks for the file

There are exactly three locations, and the file has no extension:

.github/CODEOWNERS ← checked first CODEOWNERS ← repository root docs/CODEOWNERS ← checked last

If a branch contains more than one of them, GitHub uses one file only — the first in that order. A second copy elsewhere is not merged in and not warned about; it simply has no effect, which makes “we have two CODEOWNERS files and half the rules do nothing” a genuinely common bug.

The branch matters as much as the directory. The copy that decides a pull request is the one on the base branch — the branch you are merging into — not the one on your feature branch. That has a practical consequence worth internalising: editing CODEOWNERS inside a pull request does not change who is asked to review that same pull request. Your new rules start working on the pull requests opened after the change has landed on the base branch. If you are trying to hand a directory to a new team, the ownership change and the work it covers should be two separate pull requests, in that order.

Inside the file, # starts a comment, blank lines are ignored, and every other line is one rule.

The shape of a rule

/src/billing/ @acme/payments @dana ^ pattern ^ one or more owners

A pattern, then whitespace, then zero or more owners. Two details are easy to miss:

  • Several owners on one line are all requested, and any one of them can satisfy the requirement. GitHub's documentation describes its /scripts/ @doctocat @octocat example as requiring approval from either of the two. Listing five people does not mean five approvals; it means five review requests and one needed approval.
  • A rule with no owners at all is legal and is the documented way to carve an exception out of a broader rule. It still matches, so it still wins as the last matching rule — it just requests nobody. GitHub's own example pairs /apps/ @octocat with a bare /apps/github line beneath it to leave that one subdirectory unowned.

Pattern syntax: gitignore, minus three features

GitHub says CODEOWNERS patterns “follow most of the same rules used in gitignore files, with some exceptions.” The rules it keeps are worth stating precisely, because three of them do the bulk of the damage:

  • A leading / anchors the pattern to the repository root. /docs/ is the top-level docs directory and nothing else.
  • Any other / that is not the last character also anchors it. This is the gitignore rule people forget. docs/* and /docs/* behave identically; build/logs/ means the root build/logs, not a build/logs found anywhere. Only a pattern with no slash (*.js) or with a slash only at the end (docs/) floats and matches at any depth.
  • A trailing / means “a directory” — the rule owns what is inside it, at any depth.
  • * matches any run of characters but never crosses a /; ** does cross. So apps/*/README.md matches apps/web/README.md and misses apps/web/sub/README.md, while **/migrations/ reaches a migrations directory at any depth.
  • Paths are case sensitive. /Docs/ will not match docs/readme.md, even if your laptop's filesystem is happy to pretend otherwise.
PatternMatchesDoes not match
*every file in the repo
*.jsapp.js, src/deep/util.jssrc/app.jsx
/docs/docs/a.md, docs/deep/b.mdpackages/ui/docs/guide.md
docs/docs/a.md, packages/ui/docs/guide.mda file literally named docs
docs/*docs/a.mddocs/deep/b.md
**/migrations/migrations/3.sql, a/b/migrations/2.sqla file named migrations
/apps/*/README.mdapps/web/README.mdapps/web/sub/README.md, apps/README.md

One asymmetry in that table is worth calling out because it is not obvious. A final segment without a wildcard may name a directory, and then the rule owns its contents — GitHub documents **/logs @octocat as owning “any file in a /logs directory”, trailing slash or not. A final segment with a wildcard does not descend, and again this is GitHub's own wording: docs/* “will match files like docs/getting-started.md but not further nested files like docs/build-app/troubleshooting.md.”

The three gitignore features CODEOWNERS drops are negation with !, character ranges with [ ], and escaping a leading # with a backslash. A line using any of them is not an error you have to fix before merging — GitHub skips it, so the files you thought it covered are simply unowned.

Last matching rule wins

GitHub's documentation puts it in one sentence: “Order is important; the last matching pattern takes the most precedence.” Say it to yourself as last matching rule wins, because almost every wrong prediction about a CODEOWNERS file comes from assuming the most specific rule wins instead. CSS trains that instinct, and so does the mental model of “*.js is more precise than *, so it must be stronger.” CODEOWNERS does not work that way. A broad pattern placed below a narrow one beats it.

The reading technique that follows is simple and worth making a habit: start at the bottom of the file and walk up. The first line you meet that matches the path is the answer, and you can stop. That also gives you the layout rule — put your catch-all rules at the top and your specific ones at the bottom. A stray * @some-team at the end of a file silently overrides every targeted rule above it.

Worked example 1: order beats specificity

Five rules, numbered by line. Cover the answers and resolve each path yourself; the reason for each is nothing more than “which matching rule sits lowest”.

1 * @acme/maintainers 2 *.js @acme/web 3 /docs/ @dana 4 /src/billing/ @acme/payments 5 /src/billing/README.md @dana
Changed pathResolved ownerReason
Makefile@acme/maintainersrule 1 is the only one that matches
src/app.js@acme/webrules 1 and 2 match; rule 2 is the last one that matches
docs/index.js@danarules 1, 2 and 3 match; rule 3 is the last one that matches
src/billing/charge.js@acme/paymentsrules 1, 2 and 4 match; rule 4 is the last one that matches
src/billing/README.md@danarules 1, 4 and 5 match; rule 5 is the last one that matches

Row three is the one to sit with. docs/index.js is a JavaScript file, and *.js @acme/web genuinely matches it — but rule 3 is a directory rule further down the file, so the front-end team is never asked. Nothing about rule 2 is “less specific”; it is merely earlier. Row four repeats the trick with a directory beating an extension, and row five shows the intended use of the ordering: a single file handed to one person, sitting below the directory rule that would otherwise claim it.

Note also what rule 1 does. A bare * owns every file in the repository, so in this file there is no such thing as an unowned path — which is exactly why rule 1 belongs on line 1 and would be a disaster on line 5.

Worked example 2: three ways a path ends up unowned

Same exercise, harder file. Three of these seven paths resolve to nobody — one of them on purpose and two by accident.

1 /apps/ @acme/platform 2 /apps/legacy 3 docs/* @dana 4 **/migrations/ @acme/db 5 build/logs/ @sam
Changed pathResolved ownerReason
apps/web/main.ts@acme/platformrule 1 is the last one that matches
apps/legacy/old.tsnobodyrule 2 is the last one that matches — and it lists no owners
docs/getting-started.md@danarule 3 matches a file sitting directly in docs/
docs/build-app/troubleshooting.mdnobodyno rule matches — rule 3's * does not descend
services/api/migrations/001.sql@acme/dbrule 4 — ** crosses directories
build/logs/app.log@samrule 5 matches at the repo root
services/api/build/logs/app.lognobodyno rule matches — rule 5's inner / anchors it to the root

All three nobody outcomes look identical in a pull request — no reviewer is requested — but they are not the same bug. apps/legacy/old.ts is unowned on purpose: rule 2 matched, and matching a rule that lists no owners is the documented way to carve an exception out of rule 1. The other two matched nothing at all, and that is almost certainly an accident: rule 3's author meant to own the docs tree, and rule 5's author meant to own log directories. Nothing in GitHub's interface will point at either, because both rules are perfectly valid syntax that happens to match fewer files than intended.

The fix for row four is a trailing slash instead of a wildcard: /docs/ @dana owns the whole tree, nested files included. The fix for row seven is **/build/logs/ @sam, which reaches a build/logs directory at any depth — or deciding that only the root copy should be owned and leaving rule 5 alone. Both are edits of a character or two, both are almost impossible to spot by reading the file, and both become obvious the moment you resolve the file against a real list of paths.

Owners: users, teams, emails — and write access

Three token shapes are accepted: @username for an individual, @org/team-name for a team, and a plain name@example.com email address registered on a GitHub account. Anything else on the owner side of a line is invalid syntax and the line is skipped.

Syntax is the easy half. The requirement people trip over is write access: a code owner must have write permission on the repository, and a team owner must additionally be visible rather than secret. An owner who fails that test does not cause the rule to be ignored in favour of an earlier one. The rule still wins; it just requests nobody. That is why an ex-employee's handle or a freshly created team slug can leave a directory quietly unreviewed while the file continues to look completely correct.

Two more behaviours worth knowing, both documented by GitHub: code owners are not automatically requested on draft pull requests, and CODEOWNERS on its own is only a routing mechanism. It becomes a gate only when the branch's protection rules turn on “Require review from Code Owners”. Without that setting, a reviewer can be requested and the pull request can still merge without them.

How you would notice a dead rule

Collecting the failure modes above into one list — a rule can silently own nothing because:

  • the pattern is anchored tighter than intended (any inner / pins it to the root);
  • its final segment contains a wildcard, so it does not descend;
  • the path is cased differently, and CODEOWNERS is case sensitive;
  • the directory was renamed or the path was typed wrong, and a pattern that matches nothing is not an error;
  • the line uses !, [ ] or an escaped #, so GitHub skips it entirely;
  • a later, broader rule overrides it — last matching rule wins;
  • the owner exists but lacks write access, so the rule matches and requests nobody.

Only some of those are visible for free. Viewing the CODEOWNERS file on GitHub shows an errors panel, and that is where malformed lines and owners GitHub cannot resolve surface — so a handle without write access is the one item on the list you have a fair chance of being told about. The pattern problems are the genuinely silent ones, because a valid pattern that matches zero files is not an error; it is a legal rule about a set that happens to be empty, and nothing anywhere will complain about it. Those get discovered the slow way: someone opens a pull request, no reviewer appears, and nobody notices that nobody noticed.

The cheap check is to resolve the file yourself against a list of paths you actually care about — a handful of hot files per directory, plus one deliberately nested example per directory rule, which is what catches the docs/* and build/logs/ classes of bug. That is a five-minute exercise by hand and instant with a resolver.

Check your file against real paths

Paste a CODEOWNERS and a list of paths; every path resolves to its owner with the exact deciding line, plus warnings for the !, [ ] and escaped-# syntax GitHub silently ignores. Runs entirely in your browser — nothing is uploaded.

Try the CODEOWNERS Resolver →

Putting it together

GitHub reads one CODEOWNERS per branch, from .github/, the root, or docs/, in that order — and the copy that governs a pull request is the one on the base branch, so an ownership change never applies to the pull request that makes it. Each line is a gitignore-style pattern plus zero or more owners, where a leading slash anchors to the root, any inner slash anchors it too, a trailing slash means a directory's contents, * stops at a slash and ** does not. Negation, character ranges and escaped hashes are silently skipped. When several rules match, the last matching rule wins — not the most specific — so read the file from the bottom up, keep catch-alls at the top and exceptions at the bottom. And remember that a matched rule with no valid, write-access-holding owner requests nobody at all, which looks exactly like a rule that works.

GitHub's “About code owners” is the normative reference for every rule on this page; if it ever contradicts something written here, believe GitHub.

Open the CODEOWNERS Resolver → to resolve your own file path by path, or browse all Polyatic tools.