Path-based CODEOWNERS in a monorepo Jump to heading

In a single-package repository, review routing is trivial — one team owns everything. In a monorepo hosting dozens of packages, services, and infrastructure trees under one root, that model collapses: a change to the payments service must reach the payments team, a change to the shared UI library must reach the design-systems team, and a Terraform edit must reach platform. A CODEOWNERS file is how you encode that routing declaratively, so the host requests the right reviewers automatically on every pull request. This page is a focused recipe within Monorepo Branch Topology, which covers how branching, ownership, and CI scoping fit together when many teams share one tree.

When to use this approach Jump to heading

Apply this recipe when:

  • Multiple teams commit to one repository and you need reviews routed by directory rather than assigned by hand on every pull request.
  • Changes regularly land in a package without its owning team noticing, because review assignment depends on whoever the author happened to @-mention.
  • You want required review from a specific team to be enforced by branch protection, not by social convention.
  • Ownership has drifted — directories exist with no clear owner, and nobody can answer “who reviews changes to this path?” reliably.
  • You already scope CI by path (see Optimizing CI Triggers for Path-Specific Changes) and want review routing to follow the same directory boundaries.

This recipe is not the right fit if your repository holds a single deployable owned by one team — a one-line * @org/team file is all you need — or if your host predates CODEOWNERS support and you depend instead on external review-assignment bots.

The diagram below shows how a pull request touching two packages fans out to the owning teams under last-match-wins resolution.

CODEOWNERS routing a multi-package pull request to owning teamsA pull request changing files under services/payments and packages/ui is matched against ordered CODEOWNERS rules; last-match-wins selects the payments team and the design-systems team as required reviewers, while a catch-all default owner backs any unmatched path.Pull requestservices/payments/*packages/ui/*CODEOWNERS* @org/platform/services/payments/ @org/payments/packages/ui/ @org/designlast match wins per file@org/paymentsrequired reviewer@org/designrequired reviewerunmatched path → @org/platform

Step-by-step recipe Jump to heading

Step 1 — Place the CODEOWNERS file and set a fallback owner Jump to heading

The host looks for CODEOWNERS in one of three locations, in order: the repository root, a .github/ directory (GitHub), or a docs/ directory. Pick one and keep it there; a stray second copy in another location is silently ignored. Start with a catch-all so no path is ever unowned:

# From the repository root, create the file under .github/
mkdir -p .github
cat > .github/CODEOWNERS <<'EOF'
# Default owner for anything not matched by a more specific rule below.
# Keep this as the FIRST rule so it is the weakest (see Step 2).
*                       @org/platform
EOF

Verify the host recognizes the file. On GitHub, an invalid path or unknown team surfaces in the repository’s CODEOWNERS view and via the API:

# Requires the gh CLI, authenticated against the repo's host
gh api "repos/:owner/:repo/codeowners/errors" --jq '.errors'
# An empty array [] means every line parsed and every owner resolved.

Step 2 — Order rules from general to specific for last-match-wins Jump to heading

This is the single most misunderstood aspect of CODEOWNERS: the last matching pattern wins, and it replaces — not augments — earlier matches. A file’s owners come from exactly one line. If a broad rule sits below a narrow one, the broad rule overrides it. Therefore order rules from most general at the top to most specific at the bottom:

cat > .github/CODEOWNERS <<'EOF'
# ── General → specific. The LAST match for a file decides its owners. ──

# 1. Fallback: weakest rule, must stay at the top.
*                               @org/platform

# 2. Whole subtrees owned by a team.
/services/                      @org/backend
/packages/                      @org/frontend

# 3. Specific packages override their subtree owner.
/services/payments/             @org/payments
/packages/ui/                   @org/design-systems

# 4. Narrow overrides win because they appear last.
/services/payments/webhooks/    @org/payments @org/security
EOF

Verify the ordering resolves as intended by checking which rule matches a representative file. GitHub exposes the effective owners per path:

# Ask the host which rule wins for a given file
gh api "repos/:owner/:repo/codeowners/errors" >/dev/null && \
  echo "parsed OK; confirm effective owners in the PR 'Reviewers' panel"

Note on directory depth: A pattern with no leading slash (ui/) matches that directory name anywhere in the tree; a leading slash (/packages/ui/) anchors it to the repository root. In a monorepo, always anchor package rules with a leading slash so packages/ui/ does not accidentally also claim services/legacy/ui/.

Step 3 — Choose wildcard vs directory rules per package Jump to heading

Two pattern styles cover almost every monorepo case. A directory rule ending in / owns everything beneath that directory recursively. A wildcard rule targets file types regardless of location. Use directory rules for team-owned subtrees and reserve wildcards for cross-cutting file classes:

cat >> .github/CODEOWNERS <<'EOF'

# ── Wildcards for cross-cutting file classes ──
# Every Terraform file, wherever it lives, needs platform review.
*.tf                            @org/platform
# CI workflow changes are security-sensitive across the whole repo.
/.github/workflows/             @org/platform @org/security
# But a package may still override for its own infra directory:
/services/payments/infra/       @org/payments @org/platform
EOF

Because *.tf sits above the /services/payments/infra/ line, the more specific directory rule wins for Terraform inside that package — exactly the last-match-wins behavior from Step 2. Verify a sample of paths against the file with the validation script in Step 6 before relying on any wildcard.

Step 4 — Assign team handles instead of individuals Jump to heading

Route to @org/team handles, never to individual usernames. Individuals leave, go on vacation, and become bottlenecks; a team handle load-balances review requests across its members and survives personnel changes. Ensure each team has write access to the paths it owns — an owner without write access cannot be a required reviewer and the host will treat the rule as unsatisfiable:

# List teams referenced in CODEOWNERS and confirm each exists and has repo access
grep -oE '@[A-Za-z0-9_-]+/[A-Za-z0-9_-]+' .github/CODEOWNERS | sort -u | \
while read -r handle; do
  team="${handle#@org/}"
  gh api "orgs/org/teams/${team}" --jq '.slug' \
    && echo "  ok: ${handle}" \
    || echo "  MISSING TEAM: ${handle}"
done

Verify that every referenced team resolves. Any MISSING TEAM line means a rule that will never assign a reviewer — the host reports these as errors and leaves the path effectively unowned.

Step 5 — Require Code Owner review in branch protection Jump to heading

CODEOWNERS only requests reviewers by default; it does not require them. To make owner approval mandatory, enable “Require review from Code Owners” in the branch protection rule for your integration branch. This is the same enforcement layer that gates merges in Trunk-Based Development Setup, where a protected trunk depends on required reviews to stay releasable:

# Enable required code-owner review on the default branch via the API
gh api -X PUT "repos/:owner/:repo/branches/main/protection" \
  --input - <<'EOF'
{
  "required_pull_request_reviews": {
    "require_code_owner_reviews": true,
    "required_approving_review_count": 1
  },
  "required_status_checks": null,
  "enforce_admins": true,
  "restrictions": null
}
EOF

Verify the protection is active:

gh api "repos/:owner/:repo/branches/main/protection/required_pull_request_reviews" \
  --jq '.require_code_owner_reviews'
# Expected: true

SAFETY WARNING: Setting enforce_admins: true blocks everyone, including administrators, from merging without owner approval — if a CODEOWNERS rule points at a team with no members or no write access, that path becomes unmergeable. Roll out with enforce_admins: false first, confirm coverage with Step 6, then tighten. To recover from a lockout, temporarily disable protection: gh api -X DELETE "repos/:owner/:repo/branches/main/protection".

Step 6 — Validate ownership coverage in CI Jump to heading

A catch-all * rule guarantees some owner for every file, but it hides gaps: directories silently falling through to the default team when they should have a dedicated owner. Add a validation script that fails CI when a tracked top-level directory has no explicit rule, and that rejects a CODEOWNERS file with parse errors:

#!/usr/bin/env bash
# scripts/validate-codeowners.sh — fail CI on unowned paths or invalid rules
set -euo pipefail

CODEOWNERS_FILE=".github/CODEOWNERS"
FAIL=0

# 1. File must exist and be non-empty.
if [ ! -s "$CODEOWNERS_FILE" ]; then
  echo "ERROR: $CODEOWNERS_FILE is missing or empty."
  exit 1
fi

# 2. Every top-level tracked directory should have an explicit anchored rule.
#    (The catch-all * still backs them, but we want intentional ownership.)
mapfile -t TOP_DIRS < <(git ls-tree -d --name-only HEAD | sort -u)
for dir in "${TOP_DIRS[@]}"; do
  # Skip meta directories that the default owner legitimately covers.
  case "$dir" in .github|docs) continue ;; esac
  if ! grep -qE "^/${dir}/" "$CODEOWNERS_FILE"; then
    echo "UNOWNED: /${dir}/ has no explicit CODEOWNERS rule"
    FAIL=1
  fi
done

# 3. Every owner token must be a team/org handle, not a bare username.
if grep -vE '^\s*(#|$)' "$CODEOWNERS_FILE" \
   | grep -oE '@[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)?' \
   | grep -vqE '/'; then
  echo "WARNING: a rule assigns a bare user instead of a @org/team handle"
fi

if [ "$FAIL" -ne 0 ]; then
  echo "CODEOWNERS coverage validation failed."
  exit 1
fi
echo "CODEOWNERS coverage validation passed."

Verify the script locally, then confirm the host itself reports zero parse errors:

chmod +x scripts/validate-codeowners.sh
./scripts/validate-codeowners.sh

# Cross-check against the host's own parser (GitHub)
gh api "repos/:owner/:repo/codeowners/errors" --jq 'length'
# Expected: 0

Wire the script into the same path-aware pipeline you already run for changed packages, described in Optimizing CI Triggers for Path-Specific Changes, so a pull request that adds a new top-level directory without an owner fails before merge.

Validation checklist Jump to heading

Before treating the CODEOWNERS file as authoritative, confirm each item:

Frequently asked questions Jump to heading

Why is the wrong team requested when several CODEOWNERS rules match a file? Jump to heading

CODEOWNERS resolves owners by last-match-wins: only the final matching line in the file assigns reviewers, not the union of every line that matches. When a broad rule such as /services/ appears below a specific one such as /services/payments/, the broad rule overrides the specific one and the wrong team is requested. Reorder the file so patterns run from most general at the top to most specific at the bottom, and re-check the effective owners in the pull request’s reviewer panel.

Does a later CODEOWNERS rule add owners or replace them? Jump to heading

It replaces them. Each file draws its owners from a single rule — the last one that matches — so you cannot accumulate reviewers across multiple lines. To require two teams on the same path, list both handles on one line, for example /billing/ @org/payments @org/security. Splitting them across two lines makes only the lower line take effect, dropping the other team entirely.

How do I guarantee every path in the monorepo has an owner? Jump to heading

Combine two layers. First, keep a catch-all * rule with a default team on the first line so the host always has a fallback owner. Second, run a validation script in CI that enumerates tracked top-level directories and fails the build when any of them lacks an explicit, anchored rule — the catch-all prevents unowned files, and the script prevents silent drift into the default owner. The host’s own CODEOWNERS error API and repository UI back this up by flagging unmatched patterns and unresolvable team handles.