What CODEOWNERS is and how GitHub resolves it
CODEOWNERS maps file paths to the people or teams who should review changes to them. When a pull request touches a matching path, GitHub automatically requests a review from those owners. It does not grant or restrict write access, and it should not mirror the org chart. It answers one question: who must look at this diff?
GitHub checks .github/, the repository root, and docs/ in that order, and uses the first CODEOWNERS file it finds. It reads the file from the pull request's base branch. A PR that edits CODEOWNERS therefore does not change who reviews that same PR. The new rules apply only after the change merges.
Patterns follow gitignore-style syntax, and the last matching line wins. Order carries meaning: put broad defaults first and specific overrides after them. For example:
* @org/platform: fallback owner for everything./apps/ @org/apps-team: overrides the fallback for the top-levelappstree./apps/billing/ @org/payments: overrides the line above, because it appears later./apps/billing/generated/: no owners listed, so these files end up with no owner. A later empty match removes ownership. It does not inherit it.
| Pattern | Matches | Common surprise |
|---|---|---|
/apps/ |
apps/ at repo root, recursively |
None; usually what you want |
apps/ |
Any directory named apps, at any depth |
Also catches tools/apps/ |
/apps/* |
Files directly inside /apps |
Skips nested subdirectories |
*.proto |
.proto files anywhere |
Overrides earlier directory rules if placed later |
!/apps/legacy/ |
Nothing; ! negation is unsupported |
The line does not work as an exclusion |
# .github/CODEOWNERS
# GitHub reads this file from the PR's base branch.
# The LAST matching pattern wins, so put broad rules first.
# 1. Fallback: anything not matched below goes to platform.
* @org/platform
# 2. Anchored: only the top-level apps/ tree, recursively.
# An unanchored 'apps/' would also match tools/apps/.
/apps/ @org/apps-team
# 3. More specific, and later, so it overrides rule 2 for billing.
/apps/billing/ @org/payments
# 4. No owners listed: these files end up with NO owner.
# This line does not inherit @org/payments from rule 3.
/apps/billing/generated/
# 5. Extension rule placed last: every .proto file goes to api-review,
# including apps/billing/*.proto, because this line matches later.
*.proto @org/api-review
# Do NOT write exclusions like this. '!' negation is unsupported
# and the line does not exclude anything:
# !/apps/legacy/
#
# To carve out a subtree, add a later, more specific rule
# (with or without owners) instead.
An annotated CODEOWNERS file showing a broad fallback, an anchored directory override, a later more specific override, an empty-owner line that removes ownership, and a trailing extension rule that wins over earlier directory rules.
Why monorepos use it and who should own what
In a monorepo, dozens of teams commit to one tree, and a single pull request can touch a service, a shared library and the CI config at once. Without automatic review routing, authors guess who to tag. The guess is usually whoever they worked with last, and changes to shared surfaces get merged by people who will not be paged when those changes break production. CODEOWNERS replaces the guess with a rule: the people who run the code review the code.
Who should adopt it
Adopt it if your repository has several teams, has on-call rotations tied to specific services, or has shared platform surfaces where one bad change affects everyone. A single-team repo gains little.
Mapping paths to owners
Assign each deployable service and library to the team that carries its pager. Ownership should follow operational accountability, not who wrote the code first. Then give the cross-cutting files an explicit platform owner: build configuration, CI workflows, lockfiles, root tooling and generated code. Most production incidents in this group start in these files, and they are the files most likely to go unowned.
repo/
├── services/
│ ├── billing/ @acme/payments
│ └── search/ @acme/search
├── libs/
│ └── auth/ @acme/identity
├── .github/workflows/ @acme/platform
├── tools/ @acme/platform
├── gen/ @acme/platform
├── BUILD, go.sum @acme/platform
└── CODEOWNERS @acme/platform
Use team handles (@org/team), not individuals. A team handle survives reorgs, vacations and departures. Each team must have write access to the repository. If a team lacks write access, GitHub silently ignores its entry and no review is requested.
+--------------------------------------------+
| Pull request changes: |
| services/billing/ libs/auth/ go.sum |
+-----------------------+--------------------+
|
v
+--------------------------------------------+
| CODEOWNERS (last matching pattern wins) |
| /services/billing/ @acme/payments |
| /services/search/ @acme/search |
| /libs/auth/ @acme/identity |
| /.github/workflows/ @acme/platform |
| /tools/ @acme/platform |
| /gen/ @acme/platform |
| /BUILD @acme/platform |
| /go.sum @acme/platform |
| /CODEOWNERS @acme/platform |
+-----------------------+--------------------+
|
+----------------+----------------+
v v v
+--------------+ +--------------+ +--------------+
|@acme/payments| |@acme/identity| |@acme/platform|
| on call for | | on call for | | build, CI, |
| billing | | libs/auth | | lockfiles |
+--------------+ +--------------+ +--------------+
Each team needs write access to the repo; otherwise
GitHub ignores its entry and requests no review.
A pull request that touches a service, a shared library and a lockfile is routed by CODEOWNERS to the payments, identity and platform teams. Each of those teams must have write access for its review request to be sent.
How it works with branch protection as a merge gate
Routing alone only requests reviews. The enforcement comes from branch protection (or a ruleset) on the target branch with Require review from Code Owners enabled. With that setting on, a pull request cannot merge until every changed file has an approving review from at least one of its code owners.
Two rules govern this. First, for each file, only the last matching pattern in CODEOWNERS applies. If that line lists several teams, an approval from any one member of any of those teams satisfies the file. Listed owners are alternatives, not a quorum. Second, the requirement is evaluated per file, so a PR spanning several ownership areas needs sign-off from each area.
Consider a PR touching three paths:
| Changed path | Owners on last matching line | Satisfied by |
|---|---|---|
services/billing/invoice.go |
@acme/billing |
Any billing member |
libs/auth/token.go |
@acme/identity @acme/security |
Any identity or security member |
deploy/k8s/billing.yaml |
@acme/platform |
Any platform member |
This PR needs up to three approvals. A reviewer who belongs to two of the matched teams can satisfy both files with a single review. The PR author's own approval never counts. The separate "required number of approvals" setting still applies on top of the code owner requirement.
CODEOWNERS does not control access. It grants no read or write permission and blocks no pushes. Anyone with write access can still push to unprotected branches. The file only gates merges into branches where the rule is enforced. If you need to restrict who can change a path, use repository permissions and branch protection.
Implementing it: file layout, ordering and self-protection
Because GitHub applies the last matching pattern in the file, the order of lines determines who owns what. Write the file from general to specific so that each line overrides the ones above it.
Step 1: Set a broad default
Put a * rule on the first line and assign it to a team that will actually review code. This default has a cost:
- With a catch-all, no path is ever unowned. However, the default team often becomes a rubber stamp that approves changes it doesn't understand.
- Without a catch-all, pull requests that touch unowned paths request no review at all, and new top-level directories receive no review until someone adds a rule.
A useful compromise is to keep the catch-all but treat every pull request it routes as a signal. Each one shows that a path still needs a real owner.
Step 2: Group rules by domain
Below the default, add a commented block for each domain, such as services/billing/, services/identity/ and libs/. Use one team per block. Comments help people reviewing a change to the file see which team boundary is moving.
Step 3: Add specific paths
Narrower paths go below their domain block. Examples include database migrations, protobuf definitions or a shared auth library. A narrow rule placed above its domain rule is overridden by it and has no effect.
Step 4: Protect the routing itself
The last block should assign .github/CODEOWNERS and .github/workflows/ to a platform or security team. Without this block, a contributor could edit the file to reassign review of a path, or change a workflow so it skips tests or exposes secrets (GitHub Actions docs). Placing this block last ensures that no earlier pattern overrides it.
# .github/CODEOWNERS
# The last matching pattern wins, so rules go from general to specific.
# --- Default: every path has an owner ---
# Changes routed only by this rule show a path that still needs a real owner.
* @example-org/eng-reviewers
# --- Billing domain ---
/services/billing/ @example-org/billing
# --- Identity domain ---
/services/identity/ @example-org/identity
# --- Shared libraries ---
/libs/ @example-org/core-libs
# --- Specific paths (must come after their domain rule) ---
# Schema changes need a database reviewer as well as the owning team.
/services/billing/migrations/ @example-org/billing @example-org/dba
/services/identity/migrations/ @example-org/identity @example-org/dba
# Protobuf contracts are shared across services, so API owners review them.
/proto/ @example-org/api-platform
# Shared auth code needs security review in addition to identity.
/libs/auth/ @example-org/identity @example-org/security
# --- Self-protection: keep this block last so nothing overrides it ---
# Prevents silent rerouting of reviews or changes to CI behavior.
/.github/CODEOWNERS @example-org/platform @example-org/security
/.github/workflows/ @example-org/platform @example-org/security
A complete CODEOWNERS file for a monorepo, ordered from a catch-all default through domain and path-specific rules, and ending with a final block that assigns the CODEOWNERS file and CI workflows to platform and security owners.
Validating CODEOWNERS in CI and keeping it current
GitHub catches some errors on its own. When you open the CODEOWNERS file in the web UI, it highlights lines with invalid syntax and owners it cannot resolve. That check is passive, because nobody sees it unless they open the file, and it only covers syntax. It cannot tell you that a rule is valid but wrong.
What a CI check should catch
Add a small workflow (GitHub Actions docs) that runs on every pull request touching CODEOWNERS or moving directories. The script should fail the build when:
- A tracked path has no owner. Walk
git ls-files, resolve each path against the rules, and report any path that no rule matches. - A team or user is invalid. A team must exist and have write access to the repository. Without write access, GitHub ignores it as an owner.
- A pattern is dead. If a pattern matches no files, the directory was probably renamed or deleted.
The ordering problem needs extra attention. Because the last matching rule wins, a broad pattern added near the bottom, such as *.yaml @platform, quietly takes over every service's config files from the teams listed above it. GitHub does not warn about this, and the file still looks correct. The only reliable signal is to compare each path's resolved owner before and after the change and print the differences in the PR.
Keeping it current
Review CODEOWNERS during every reorganization and every service move, not months later. Treat changes to it as normal code: reviewed, scoped, and explained in the PR description.
Prune owners who have left the team or no longer know the code. Stale ownership is worse than none. With no owner, everyone can see the gap. A stale owner makes a path look covered while its reviews sit in an inactive queue or get approved without real scrutiny.
name: codeowners-check
# Run on every PR: renames and deletions anywhere in the repo can
# break ownership without touching the CODEOWNERS file itself.
on:
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # base branch history is needed for the before/after diff
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# Fails on: tracked paths with no owner, patterns matching no files,
# and teams/users that don't exist or lack write access.
# The default GITHUB_TOKEN usually cannot read org teams, so this
# uses a separate read-only token stored as a repository secret.
- name: Validate ownership rules
env:
GH_TOKEN: ${{ secrets.CODEOWNERS_CHECK_TOKEN }}
run: |
python scripts/check_codeowners.py \
--codeowners .github/CODEOWNERS \
--repo "${{ github.repository }}"
# Resolve every path's owner on the base branch and on this PR, then
# print what changed. A late broad rule like `*.yaml @platform`
# shows up here as many paths switching owners at once.
- name: Show resolved-owner changes
run: |
python scripts/check_codeowners.py \
--codeowners .github/CODEOWNERS \
--diff-base "origin/${{ github.base_ref }}"
A minimal GitHub Actions workflow that runs a team-maintained check_codeowners.py script on every pull request. The script fails the build on unowned paths, dead patterns, or invalid owners, then prints how each path's resolved owner changed relative to the base branch.
When to avoid it and the trade-offs to manage
When to skip it or keep it light
CODEOWNERS adds little in a small repository or a codebase owned by one team. Everyone already reviews everything, so the file only adds mandatory approvals and doesn't route reviews anywhere useful. It also fails if the owning team can't review within a day or two. Then the required approval stops working as a quality gate and just slows merges. It is not access control either. Anyone with write access can still edit the files. CODEOWNERS only decides who must approve before a merge.
Trade-offs to manage
Required reviews from an overloaded or inactive team turn that team into a bottleneck. Time-zone gaps make it worse: a PR opened late in one region waits a full day for its owner. How finely you split ownership decides where that cost falls.
| Aspect | Fine-grained ownership | Coarse ownership |
|---|---|---|
| Reviewer expertise | High, close to the code | Broader, sometimes shallow |
| Approvals per cross-cutting PR | Many | Few |
| Maintenance burden | High, drifts with reorganisations | Low |
| Bottleneck risk | Spread across many small queues | Concentrated in a few large queues |
Dependency bumps, codemods and renames can touch dozens of owned paths and pull in every team. To limit that:
- Split mechanical PRs by owner.
- Give the lockfile a narrow owner instead of every consumer.
- Schedule bulk changes so teams can plan review time.
Security patches and repo-wide refactors need a documented bypass, such as a small group allowed to override branch protection. Every use should be logged and reviewed afterwards.
Alternatives and impact on cost and engineering time
CODEOWNERS is not the only way to route reviews. The alternatives below differ mostly in where ownership data lives and whether anything enforces it.
| Approach | What it gives you | What it costs |
|---|---|---|
| No ownership file, manual reviewer selection | Zero setup | Authors guess reviewers. Reviews get missed or go to whoever answers fastest. |
| Split into multiple repositories | Hard boundaries through separate permissions | Cross-repo changes, duplicated CI and version drift |
| Service catalog or per-directory files read by custom tooling | Richer metadata such as on-call rotation and tier | You build and maintain the tooling. Merges are not gated unless you wire that up. |
| Repository permissions | Real access control over who can push | Says nothing about who should review |
These options can be combined. Permissions control access, and CODEOWNERS controls review routing. A catalog can generate the CODEOWNERS file so the two never disagree.
The costs fall into three groups. Mapping paths to teams is a one-off effort. Keeping the file current is ongoing work. Review latency can increase when a single owning team becomes a bottleneck. The savings come from automatic routing, fewer misrouted reviews and an unambiguous answer to "who owns this?" during an incident.
Illustrative estimate (assumed numbers, not a benchmark). Use this formula:
Weekly hours saved = (PRs per week × minutes spent finding a reviewer ÷ 60) − weekly maintenance hours
Assume 150 PRs per week, 4 minutes each spent finding a reviewer and 2 hours per week of maintenance:
(150 × 4 ÷ 60) − 2 = 8 hours per week
Swap in your own figures. If the result is near zero, the clearer on-call accountability may still justify the file.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monorepo code ownership, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.