Skip to content

CODEOWNERS: Mapping Repository Paths to Owners

Lesson 9 of 12Intermediate10 min readGitHub Engineering · Pull RequestsVerified: GitHub.com, August 2026

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.

CODEOWNERS is a plain text file in one of three locations:

.github/CODEOWNERS
CODEOWNERS
docs/CODEOWNERS

Only 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.

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/

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-team

For /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”
PatternMatches
/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
*.mdMarkdown files at any depth
/docs/*.mdMarkdown 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.

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.

To restate the distinction, because it is the point of the lesson:

MechanismEffect
RoutingCODEOWNERSOwners are automatically requested as reviewers
EnforcementBranch protection / rulesetOwner 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.

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.

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/security

Two 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.

There is no CLI command that resolves ownership for a path, but the API reports which reviewers were requested — which is the observable effect:

Terminal window
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.

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.

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:

Terminal window
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.

A reference for the cases that behave unexpectedly:

IntentPatternNote
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 depthsrc/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/*.sqlNot /db/migrations/*.sql
A specific file/Dockerfile
Explicitly unowned/vendor/ with no ownerOverrides 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.

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.

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:

Terminal window
gh api "orgs/ORG/teams?per_page=100" --paginate --jq '.[].slug' | sort > /tmp/teams.txt
grep -oE '@ORG/[a-z0-9-]+' .github/CODEOWNERS | sed 's|@ORG/||' | sort -u > /tmp/owners.txt
comm -13 /tmp/teams.txt /tmp/owners.txt

Anything 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.

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.

  1. Add .github/CODEOWNERS with a catch-all line at the top and a more specific directory rule below it.
  2. Open a pull request touching only the specific directory and check gh pr view --json reviewRequests — confirm only the specific owner was requested.
  3. Move the catch-all to the bottom of the file and repeat. Observe that it now owns everything.
  4. Add a deliberately misspelled username and confirm it produces no error and no request.
  5. 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.

  • 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.

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-leads

Three 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.

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:

Terminal window
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 CODEOWNERS
on:
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
fi

Note 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.

Check your understanding

3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

A CODEOWNERS file has `/docs/ @docs-team` on line 1 and `* @core-team` on line 5. Who is requested for `docs/guide.md`?
Show answer

`@core-team` only — the last matching line wins, and `*` matches everything — Matches do not accumulate: only the last matching line applies. A catch-all `*` at the bottom therefore owns everything and silently disables every rule above it. Order general to specific.

A CODEOWNERS file lists `/src/* @backend`. A pull request changes `src/api/users.js`. Is `@backend` requested?
Show answer

No — a single `*` does not cross directory boundaries — `/src/*` matches files directly in `src/`, not in subdirectories. Use `/src/` or `/src/**` for nested paths.

A team is listed in CODEOWNERS but has only read access to the repository. What happens?
Show answer

The entry is silently ignored and no review is requested — Owners without write access are ignored without any error. The lesson calls this the most common silent failure — review simply stops being requested.

Professional ToolkitNeed a production-ready CODEOWNERS template? The annotated one is in the Professional Toolkit.