Module ownership vs. the CODEOWNERS file

Module ownership and the CODEOWNERS file are often treated as the same thing, but they answer different questions. Module ownership is accountability. The owning team decides the module's design, triages its bugs, carries the pager when it fails, schedules dependency upgrades, and runs deprecations to completion. CODEOWNERS is a review-routing mechanism. It maps file paths to reviewers, and when branch protection requires code-owner approval, it can block a merge until one of them signs off.

Aspect Module ownership CODEOWNERS file
Question answered Who is accountable for this code? Who must review this change?
Unit Logical module or domain File path patterns
Scope Design, bugs, on-call, upgrades, deprecations Pull request review requests and approval
Enforced by Team agreements, incident and planning processes The code host, at merge time
Failure mode Orphaned code nobody maintains Stale entries that block merges or route to the wrong team

The ownership map should come first, and the CODEOWNERS file should be generated from it or validated against it. Path patterns are an implementation detail. If you hand-edit CODEOWNERS as the source of truth, it drifts from the real team structure, and the drift stays hidden until a pull request stalls on an approver who left months ago.

This mostly matters to backend and platform engineers in large codebase teams. Explicit ownership becomes necessary once several teams commit to one repository, once nobody can say who gets paged when a given code path fails, or once upgrades stall because no team believes the module is theirs.

    +------------------------------------------------+
    | OWNERSHIP MAP  (source of truth)               |
    | logical module / domain  ->  owning team       |
    +------------------------------------------------+
           |                              |
           | accountable for              | generated from map, or
           |                              | CI check fails on drift
           v                              v
+----------------------+   +------------------------------+
| OWNING TEAM          |   | CODEOWNERS FILE              |
| - module design      |   | path pattern -> reviewers    |
| - bug triage         |   | (implementation detail)      |
| - on-call / pager    |   +------------------------------+
| - upgrades           |                  | read at merge time
| - deprecations       |                  v
+----------------------+   +------------------------------+
                           | CODE HOST                    |
                           | branch protection requires   |
                           | code-owner approval          |
                           +------------------------------+
                                          |
                                          | request review; block
                                          | merge until approved
                                          v
                           +------------------------------+
                           | PULL REQUEST                 |
                           +------------------------------+

The ownership map is the source of truth: it assigns teams accountability for design, bugs, on-call, upgrades and deprecations, and CODEOWNERS is generated from it or validated against it so the code host routes reviews and enforces approvals at merge time.

How CODEOWNERS matching works and how to test it

Each line in a CODEOWNERS file pairs a path pattern with one or more owners. The patterns follow gitignore conventions: *.sql matches SQL files anywhere, /billing/ anchors to the repository root, and docs/** matches everything beneath a directory. The syntax is not fully gitignore-compatible, though. Negation with ! is not supported, for example, so you cannot carve an exception out of a broad rule.

Precedence is the part that causes trouble. The last matching pattern wins. Patterns are not merged and the most specific one does not get priority. Suppose /billing/ is assigned to the payments team near the top of the file, and someone later appends *.py @platform-team at the bottom. Every Python file in billing now routes only to platform. Nothing warns you, and payments stops seeing changes to its own code.

The fix is structural. Order the file from general to specific: catch-all defaults first, then directories, then individual high-risk files. Assign team handles rather than individuals, so that vacations and departures do not leave paths unowned. To check which rule applies to a path, scan the file from the bottom up and stop at the first match. You can also open the file in your hosting platform's web view, which typically shows the resolved owner.

Granularity is a trade-off. Fine-grained rules route reviews precisely, but they break whenever files move. Coarse rules are easy to maintain, but they flood teams with irrelevant requests, and reviewers learn to approve without reading.

# .github/CODEOWNERS
# Rule: the LAST matching pattern wins. Order from general to specific.
# Use team handles (@org/team), not individuals, so paths stay owned
# through vacations and departures.

# 1. Catch-all default: anything not matched below goes here.
*                         @example-org/platform-team

# 2. File-type rules belong ABOVE directory rules. Placed here, they apply
#    only where no later directory rule matches.
*.sql                     @example-org/data-team

# 3. Directory ownership, anchored to the repo root with a leading slash.
/billing/                 @example-org/payments-team
/search/                  @example-org/search-team
/docs/**                  @example-org/docs-team

# 4. Individual high-risk files last, so nothing below can override them.
#    Multiple owners: each team listed is requested for review.
/billing/ledger/reconcile.py  @example-org/payments-team @example-org/finance-eng
/db/migrations/           @example-org/data-team @example-org/platform-team

# ANTI-PATTERN: do not append broad rules at the bottom.
# If this line were uncommented here, every .py file, including all of
# /billing/, would route ONLY to platform-team. Payments would silently
# stop being requested. There is no '!' negation to carve out exceptions.
# *.py                    @example-org/platform-team

# Manual check: to find the owner of a path such as
# billing/invoices/tax.py, read this file from the bottom up and stop at
# the first pattern that matches. Here that is /billing/ (payments-team).

A CODEOWNERS file ordered from a catch-all default to directories to individual files, using team handles, with a commented-out bottom-of-file *.py rule showing how last-match-wins would override the billing team.

Drawing module boundaries and handling shared code

Start from the code's actual seams. Top-level packages, architectural layers (API, domain, persistence) and bounded contexts such as billing or search are boundaries the code already has. Team names change more often than these do, so an ownership map built from the org chart drifts every time a team splits.

Then look for two failure modes. Gaps are paths that no rule matches. Changes to them merge with nobody accountable, and their quality decays. Overlaps occur when a typical change touches files owned by several teams. Each team must approve, which adds review delay, and reviewers start approving parts of the change they never read. Generate the list of tracked files, resolve each path against the ownership rules, and report both cases in CI.

Some files cut across every module: build config, dependency manifests, lockfiles, CI pipelines, generated code, migrations and common utilities. You have three main options for them:

Option Benefit Risk
Platform team owns them One accountable group with deep context Bottleneck. Every dependency bump waits on one queue
Rotating stewards Spreads load and knowledge Accountability fades between rotations
Exclude generated files from blocking review Removes noise from regenerated output Hand edits and generator changes slip through unreviewed

Path rules have a hard limit: they match files, not meaning. A shared data model or API contract may be defined in one directory and depended on everywhere. Changing it affects consumers that no path rule can identify. That kind of semantic ownership has to come from contract tests, schema review or an explicit consumer list.

Implementing the ownership map and enforcing it in CI

Ownership metadata belongs next to the code it describes, or in one map that is easier to audit than a hand-edited CODEOWNERS file. Two layouts work well:

  • Per-module metadata files: each module carries a small file naming its owning team, an escalation contact and, optionally, a list of paths it does not own. Ownership moves with the directory when someone relocates it.
  • A central map: one structured file that maps module paths to teams. It is easier to review in a single diff, but it drifts more easily when directories move.

In either case, a script generates CODEOWNERS from the source of truth, and CI fails if the committed file differs from what the script produces. The generated file should begin with a comment saying it is generated, so nobody hand-edits it.

On top of the regeneration check, CI should fail when:

  • A new top-level directory has no owner. Unowned code gets merged quietly, and later nobody is responsible for it.
  • A referenced team does not exist or lacks write access. Check this against your hosting platform's API, because in most setups an invalid owner is ignored rather than rejected. Review routing then breaks without anyone being told.
  • A rule matches no files. Rules like this are left over from moves and deletions. They also make the ownership map less believable.

Give the metadata, the generator and CODEOWNERS itself a platform-team owner, and review changes to them like any other code.

Stale ownership does more harm than having none. A missing owner is visible: reviewers know they have to find someone. A wrong owner sends reviews to a team that no longer understands the code. That team approves to clear its queue, and the approval misleads everyone who sees it.

#!/usr/bin/env python3
"""Generate CODEOWNERS from per-module OWNERS.json files; exit non-zero on drift or gaps."""
import json, pathlib, sys

ROOT = pathlib.Path(".")
HEADER = "# GENERATED by tools/gen_codeowners.py from OWNERS.json files. Do not edit by hand.\n"
errors, rules = [], []

# Shallow modules first: CODEOWNERS uses the last matching rule, so nested modules must come later.
for meta in sorted(ROOT.rglob("OWNERS.json"), key=lambda p: (len(p.parts), str(p))):
    module = meta.parent
    team = json.loads(meta.read_text())["team"]  # e.g. "@example-org/payments"
    # A rule that covers nothing but its own metadata file is left over from a move or deletion.
    if not any(p.is_file() and p.name != "OWNERS.json" for p in module.rglob("*")):
        errors.append(f"{module}: rule matches no files")
    pattern = "*" if module == ROOT else f"/{module.as_posix()}/"
    rules.append(f"{pattern} {team}")

# Every top-level directory needs an explicit owner. Skip dot-directories such as .git and .github.
owned = {r.split()[0].strip("/").split("/")[0] for r in rules}
for d in sorted(p for p in ROOT.iterdir() if p.is_dir() and not p.name.startswith(".")):
    if d.name not in owned:
        errors.append(f"{d.name}/: top-level directory has no OWNERS.json")

# The ownership tooling is code too. Keep these rules last so they take precedence.
rules.append("/tools/gen_codeowners.py @example-org/platform")
rules.append("/.github/CODEOWNERS @example-org/platform")
generated = HEADER + "\n".join(rules) + "\n"

# Checking that each team exists and has write access needs the hosting platform's API.
# Run that as a separate CI step that reads the same OWNERS.json files.
target = ROOT / ".github" / "CODEOWNERS"
if "--write" in sys.argv:
    target.write_text(generated)
elif not target.exists() or target.read_text() != generated:
    errors.append("CODEOWNERS is out of date; run tools/gen_codeowners.py --write")

for e in errors:
    print(f"error: {e}", file=sys.stderr)
sys.exit(1 if errors else 0)

A standard-library Python script that builds CODEOWNERS from per-module OWNERS.json files and fails CI when the committed file has drifted, a top-level directory has no owner, or a rule covers no files.

When to avoid mandatory code-owner approval and what to use instead

Mandatory approval on every path creates a similar problem. When branch protection requires a code-owner review for every matched file, the cost of a change grows with the number of teams it touches, not with its risk. A logging-library upgrade, a package rename, or a repo-wide lint fix can touch thirty ownership areas. The author then spends days collecting approvals from reviewers who skim a mechanical diff and click approve. Teams adapt in predictable ways. Some bundle unrelated work into one oversized PR so they only negotiate approvals once. Others build a habit of asking an admin to merge over the rule. Both outcomes are worse than the review they avoid.

A better approach is to put the hard gate only where a bad merge is expensive or hard to reverse:

Path category Review mode Rationale
Auth, payments, schema migrations, infrastructure config Required code-owner approval Errors leak data, lose money, or cannot be rolled back
Domain modules Advisory (owner auto-requested, not blocking) Owners see changes without blocking mechanical edits
Cross-cutting refactors Documented escalation: one designated approver per refactor Replaces thirty sign-offs with one accountable decision
Incidents Audited emergency bypass, reviewed after merge Keeps speed while leaving a trail

The goal is to avoid two failure modes. Strict gates turn owners into gatekeepers who slow everyone down. With no gates, code goes unowned and decays. Advisory review only works if owners actually read what they are routed. If notifications pile up unread, ownership has become ceremony. Track how often owners comment on advisory PRs and treat silence as a signal to reassign the module.

Impact on cost and engineering time

That silence is also the cheapest cost signal you have. Before building dashboards, use what your forge and CI already record:

  • Time to first review, per owning team. A team whose median is much higher than its peers is a routing bottleneck.
  • Share of PRs blocked only on code-owner approval. These PRs are approved by peers and green in CI but still waiting.
  • Paths frequently changed by non-owners. These are candidates for reassignment, splitting, or advisory-only ownership.

Illustrative estimate (assumed numbers, not a benchmark). Assume a change touches N = 3 owned modules, each team's time to first review W = 4 working hours, and the PR needs R = 2 review rounds. If teams review in parallel and approvals survive new commits, wait ≈ R × max(W) = 8 hours. If each push dismisses stale approvals and teams re-review one after another, wait approaches R × (W₁ + W₂ + W₃) = 24 working hours, about three working days for the same diff. Reviewer effort scales as N × R × (minutes per review). At an assumed 20 minutes, that is 2 reviewer-hours. Cross-cutting PRs cost more because waits add up, not just because there are more reviewers.

Where routing saves time Where routing adds cost
Fewer irrelevant review requests and less notification noise Keeping the ownership map correct as teams and paths change
Incident triage starts with a named team, not a search through history Shared files such as build config and lockfiles turn one team into a gate
Reviewers have the context, so fewer review rounds Required approvals multiply across modules on cross-cutting changes

Keeping ownership alive through reorgs and using it to guide modularization

Ownership maps decay quietly. A team is renamed, split, or merged, and the CODEOWNERS entries still point at a group nobody watches. Reviews then stall, or someone with admin rights bypasses them. The fix is procedural. Add an ownership review to the checklist for every team change, next to updating the on-call rotation. Whoever approves the reorg also approves the diff to the ownership map. A team change is not finished until every path it owned has a named successor.

Code ownership and operational ownership should resolve to the same team. Put the owning team as metadata on alerts, dashboards, and runbooks, using the same team identifiers that the ownership map uses. When an alert fires for the billing module, the page should reach the team that reviews billing changes. A CI check can compare the two sources and flag drift, such as an alert owned by a team that no longer appears in the map.

The ownership map also shows where the architecture is weak. Watch for three patterns:

  • Contested paths. These are paths that two teams both claim or that neither will accept. They usually mix responsibilities that belong in separate modules.
  • Hot modules. These are modules touched by a large share of all changes. They tend to be leaking internals that other code depends on.
  • Cross-team approvals. These are changes that routinely need approval from several teams, which means those modules are tightly coupled.

Fix these inside the monolith first. Define internal interfaces, narrow what each module exports, and move shared logic behind a clear owner. If the coupling persists after that work, extracting a service will not remove it. The calls will cross the network instead of a function boundary.

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 code ownership in a monolith, 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.