CODEOWNERS maps repository paths to responsible users or teams; review enforcement is a separate repository-policy decision.
That sentence contains the two facts people most often get wrong. CODEOWNERS routes — it decides who gets asked. Whether their approval is required is configured elsewhere, in branch protection or a ruleset.
Where the file goes
Section titled “Where the file goes”CODEOWNERS is a plain text file in one of three locations:
.github/CODEOWNERSCODEOWNERSdocs/CODEOWNERSOnly one is used. If several exist, .github/ wins, then the root, then docs/. Most projects use
.github/CODEOWNERS, keeping repository metadata together.
The file is read from the base branch of the pull request, not from the head branch. This is a security property rather than an implementation detail: without it, anyone could add themselves as owner of the whole repository in the same pull request they want approved.
Syntax
Section titled “Syntax”Each line is a pattern followed by one or more owners. Patterns follow gitignore-style matching;
owners are @username, @org/team-name, or an email address matching a GitHub account.
# Everything, unless a later rule matches* @acme/platform-team
# By directory/src/api/ @acme/api-team/src/web/ @acme/frontend-team/infra/ @acme/sre @alice
# By extension, anywhere*.sql @acme/data-team
# A single critical file/.github/workflows/ @acme/security/CODEOWNERS @acme/platform-leads
# Explicitly unowned — no owner is requested/vendor/Precedence: the last match wins
Section titled “Precedence: the last match wins”This is the rule that causes most CODEOWNERS bugs, and it is the opposite of how .gitignore
specificity intuitions usually run.
For any given file, GitHub scans the file top to bottom and the last matching line determines ownership. Earlier matches are discarded entirely — ownership does not accumulate.
*.js @acme/frontend-team/src/api/ @acme/api-teamFor /src/api/handler.js, the owner is @acme/api-team alone. The frontend team is not also
requested, because the later line replaced the earlier match rather than adding to it.
The practical consequence: order from general to specific. A catch-all * line belongs at the
top. Putting it at the bottom makes it own everything and silently disables every rule above it —
which is a genuinely common mistake and looks like CODEOWNERS being broken.
Patterns that behave differently than expected
Section titled “Patterns that behave differently than expected”| Pattern | Matches |
|---|---|
/src/ | Everything under src/ at the repository root |
src/ | Any directory named src at any depth |
/src/* | Direct children of src/ only — not nested subdirectories |
*.md | Markdown files at any depth |
/docs/*.md | Markdown directly in docs/, not in docs/guides/ |
The /src/* case catches people regularly. A single * does not cross directory boundaries, so
deeply nested files fall through to whatever earlier rule matched — often the catch-all.
Owners must actually have access
Section titled “Owners must actually have access”An owner entry is ignored if the user or team cannot read the repository. A misspelled username, a team that was renamed, or someone who left the organisation produces no error — the line simply does not match anyone, and reviews are silently not requested.
This is the most common way CODEOWNERS quietly stops working. Nothing fails; reviews just stop being requested, and it can be months before anyone notices.
Teams must also have explicit access to the repository, and generally need write permission for their review to satisfy a code owner requirement. A team with read-only access can be listed and will not function as an owner.
Routing versus enforcement
Section titled “Routing versus enforcement”To restate the distinction, because it is the point of the lesson:
| Mechanism | Effect | |
|---|---|---|
| Routing | CODEOWNERS | Owners are automatically requested as reviewers |
| Enforcement | Branch protection / ruleset | Owner approval is required before merge |
With CODEOWNERS alone, owners are asked and anyone’s approval satisfies the numeric requirement. With “require review from code owners” enabled, the specific owners must approve.
Two further behaviours worth knowing:
- Draft pull requests do not trigger routing. Owners are requested when the pull request is marked ready. A draft that sits unreviewed was never sent to anyone.
- Authors cannot satisfy their own ownership. If you own the path you changed, someone else must still approve.
What CODEOWNERS is not
Section titled “What CODEOWNERS is not”It is not load balancing. CODEOWNERS requests all matching owners, or — for a team — routes according to the team’s own review assignment settings if configured. The file itself has no notion of distributing work, rotating reviewers, or considering who is on holiday. If you need round-robin assignment, that is a team setting or automation, covered in Automatically Assigning Reviewers.
It is not access control. Being listed as an owner grants no permissions. Repository access is configured separately; CODEOWNERS only decides who is asked to look.
It is not documentation of expertise. It is a routing table that produces review requests. If it lists people who no longer work on that code, it produces noise that trains people to ignore review requests.
It does not protect itself by default. Anyone who can push to the base branch can change
ownership — which is why /CODEOWNERS should usually list a small trusted group as its own owner, as
in the example above.
Monorepos
Section titled “Monorepos”CODEOWNERS earns its keep most clearly in a monorepo, where a single repository contains work owned by many teams.
* @acme/platform-team
/services/checkout/ @acme/payments/services/search/ @acme/search/services/identity/ @acme/identity @acme/security
/libs/shared-ui/ @acme/frontend-team/libs/protocol/ @acme/platform-team @acme/api-team
/infra/terraform/ @acme/sre/.github/workflows/ @acme/securityTwo design notes. The catch-all sits first so anything unclaimed has an owner. And
/.github/workflows/ is owned by security regardless of which service it belongs to, because CI
configuration runs with repository credentials and a change there is a privilege change, not a code
change.
The failure mode in monorepos is a pull request touching six services, which requests six teams and merges slowly. That is CODEOWNERS working correctly and telling you the pull request is too broad — see PR Best Practices.
Inspecting ownership
Section titled “Inspecting ownership”There is no CLI command that resolves ownership for a path, but the API reports which reviewers were requested — which is the observable effect:
gh pr view PULL_NUMBER --json reviewRequests --jq '.reviewRequests'What it doesShows which users and teams were requested as reviewers on a pull request.
Why we run itThis is how you confirm CODEOWNERS is actually routing. If the expected team is absent, the pattern did not match or the owner lacks access.
Expected resultTwo arrays, of user logins and team slugs.
To check the file itself is valid, view it in the repository interface, which flags unknown owners and syntax errors that are invisible in a plain text diff.
Maintenance
Section titled “Maintenance”CODEOWNERS rots quietly. Three habits keep it honest:
Own it. Make the file itself owned by a group who will notice changes.
Review it when teams change. A reorganisation invalidates ownership immediately, and nothing signals it.
Prune ownership nobody acts on. If a team is requested on every pull request and approves without reading, ownership is producing noise rather than review. Removing the entry is more honest than maintaining the appearance of oversight.
Testing a CODEOWNERS file
Section titled “Testing a CODEOWNERS file”Ownership rules are easy to get subtly wrong and the failure is silent, so testing before relying on it is worth the few minutes.
Check the file is valid. The repository interface renders CODEOWNERS with syntax errors and unknown owners flagged. This is the only place unknown owners are surfaced — the API does not report them, and nothing fails.
Test with a real pull request. Create a branch touching one file in each owned area, open a draft, mark it ready, and inspect what was requested:
gh pr view PULL_NUMBER --json reviewRequests \ --jq '{users: [.reviewRequests[].login // empty], teams: [.reviewRequests[].name // empty]}'If an expected team is absent, the cause is one of three things: the pattern did not match, the team lacks repository access, or the pull request is still a draft.
Test the precedence. Deliberately create a file matching two rules and confirm the later one owns it. This is the behaviour most likely to surprise, and confirming it once on a real repository is worth more than reading about it.
Patterns worth getting right
Section titled “Patterns worth getting right”A reference for the cases that behave unexpectedly:
| Intent | Pattern | Note |
|---|---|---|
| Everything | * | Put it first; it matches last otherwise |
| A top-level directory and all below it | /src/ | Leading slash anchors to the root |
| Any directory of that name, at any depth | src/ | No leading slash |
| Direct children only | /src/* | Does not match nested files |
| Everything under, at any depth | /src/** | Explicit recursion |
| One file type anywhere | *.sql | |
| One file type in one place | /db/*.sql | Not /db/migrations/*.sql |
| A specific file | /Dockerfile | |
| Explicitly unowned | /vendor/ with no owner | Overrides an earlier catch-all |
The unowned form is more useful than it looks. Vendored dependencies, generated clients and imported third-party code have no meaningful owner, and leaving them matched by a catch-all means the platform team is requested on every dependency bump.
Ownership as documentation
Section titled “Ownership as documentation”CODEOWNERS is a routing table, and it is also the most reliably current map of who owns what in a codebase — precisely because it is enforced. Documentation drifts silently; a routing table that misroutes gets noticed.
That makes it worth structuring for reading as well as matching:
# Default owner for anything not claimed below.* @acme/platform-team
# ---- Services ----------------------------------------------------------/services/checkout/ @acme/payments/services/search/ @acme/search/services/identity/ @acme/identity @acme/security
# ---- Shared libraries --------------------------------------------------# Changes here affect every service; both teams review./libs/protocol/ @acme/platform-team @acme/api-team/libs/shared-ui/ @acme/frontend-team
# ---- Infrastructure and CI ---------------------------------------------# Workflow files run with repository credentials — a change here is a# privilege change, not a code change./.github/workflows/ @acme/security/infra/terraform/ @acme/sre
# ---- Unowned -----------------------------------------------------------/vendor//generated/Comments explaining why an owner is unusual — as on the workflows line — are the difference between a rule people follow and one they route around because it looks arbitrary.
Ownership and organisation structure
Section titled “Ownership and organisation structure”CODEOWNERS encodes your team structure, which means it becomes wrong whenever that structure changes and nothing tells you.
Two habits help. Review it during any reorganisation, alongside the other systems that encode team boundaries. And audit for teams that no longer exist:
gh api "orgs/ORG/teams?per_page=100" --paginate --jq '.[].slug' | sort > /tmp/teams.txtgrep -oE '@ORG/[a-z0-9-]+' .github/CODEOWNERS | sed 's|@ORG/||' | sort -u > /tmp/owners.txtcomm -13 /tmp/teams.txt /tmp/owners.txtAnything printed is an owner referenced by CODEOWNERS that does not exist as a team — silently matching nobody, and silently not requesting the review you believe is happening.
Common mistakes
Section titled “Common mistakes”Catch-all * at the bottom. It matches last, so it owns everything and disables every rule above.
Expecting matches to accumulate. Only the last matching line applies.
/src/* for nested files. A single * does not cross directory boundaries.
Misspelled or departed owners. Silently match nobody; review stops being requested.
Teams with read-only access. Listed, ignored.
Assuming it enforces. Routing and enforcement are separate settings.
Expecting load balancing. CODEOWNERS requests owners; it does not distribute work.
Leaving CODEOWNERS unowned. Anyone who can push can reassign ownership.
Exercise
Section titled “Exercise”- Add
.github/CODEOWNERSwith a catch-all line at the top and a more specific directory rule below it. - Open a pull request touching only the specific directory and check
gh pr view --json reviewRequests— confirm only the specific owner was requested. - Move the catch-all to the bottom of the file and repeat. Observe that it now owns everything.
- Add a deliberately misspelled username and confirm it produces no error and no request.
- Enable “require review from code owners” and confirm the merge is now blocked until the owner approves.
Steps 3 and 4 are the two failure modes that account for most real CODEOWNERS problems, and both are silent.
What you learned
Section titled “What you learned”- CODEOWNERS routes review requests; a separate policy setting makes owner approval required.
- The file is read from the pull request’s base branch, which prevents self-granted ownership.
- The last matching line wins and earlier matches are discarded — order general to specific.
/src/*does not match nested files; a single*does not cross directories.- Owners without repository access are silently ignored, which is the most common silent failure.
- Draft pull requests do not trigger routing until marked ready.
- CODEOWNERS is not load balancing, not access control, and does not protect itself by default.
A starting file
Section titled “A starting file”For a repository with no CODEOWNERS, this is a reasonable first version:
# Everything, unless claimed below. Keep this line first — the LAST match wins,# so a catch-all at the bottom would silently own the entire repository.* @acme/platform-team
# CI configuration runs with repository credentials. A change here is a# privilege change, not a code change./.github/workflows/ @acme/security
# The ownership map itself./.github/CODEOWNERS @acme/platform-leadsThree lines, and two of them exist for reasons that are not obvious.
Owning /.github/workflows/ separately matters because workflow files execute with the repository’s
token — someone modifying one is changing what automation can do, which deserves different scrutiny
from a change to application code.
Owning CODEOWNERS itself prevents anyone with write access from quietly reassigning ownership,
including their own.
Add path-specific owners as the codebase acquires genuine boundaries. Adding them before those boundaries exist produces a routing table that misroutes, which is worse than no routing at all.
Validating CODEOWNERS in CI
Section titled “Validating CODEOWNERS in CI”The silent failure described earlier — an owner that matches nobody — is worth catching automatically, because nothing else will.
GitHub exposes the parsed file with its errors:
gh api "repos/OWNER/REPO/codeowners/errors" \ --jq '.errors[] | {line, column, kind, message, suggestion}'{ "column": 29, "kind": "Unknown owner", "line": 7, "message": "Unknown owner on line 7: make sure the user or team exists and has write access", "suggestion": "make sure @acme/paymnets exists and has write access to the repository"}That endpoint is the whole solution. A workflow running it on every change to the file turns a silent misroute into a failed check:
name: Validate CODEOWNERSon: pull_request: paths: ['.github/CODEOWNERS']
permissions: contents: read
jobs: validate: runs-on: ubuntu-latest steps: - env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | errors=$(gh api "repos/${GITHUB_REPOSITORY}/codeowners/errors" --jq '.errors | length') if [ "$errors" -gt 0 ]; then gh api "repos/${GITHUB_REPOSITORY}/codeowners/errors" \ --jq '.errors[] | "line \(.line): \(.message)"' exit 1 fiNote the endpoint reads the file from the default branch by default; pass ?ref= to check the
version in the pull request, which is what you actually want here.
Related lessons
Section titled “Related lessons”Check your understanding
3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.