A few months ago, a teammate opened a pull request that touched our Terraform modules, a handful of Ansible roles, and a CI pipeline config. Three different areas of the codebase, three different people who should have reviewed it. What actually happened? The PR sat for two days because nobody was sure whose responsibility it was. When someone finally picked it up, they approved the pipeline changes they understood but missed a breaking change in the Terraform module — something the infra team would have caught immediately.
That PR made it to main. The breaking change made it to staging. We caught it before production, but just barely. The postmortem wasn’t about bad code — it was about the wrong eyes on the right files.
That’s when I set up a CODEOWNERS file. Twenty minutes of work, and we never had that problem again.
What Is CODEOWNERS?
A CODEOWNERS file tells GitHub who is responsible for reviewing specific parts of your repository. When someone opens a pull request, GitHub reads the file, matches the changed files against ownership patterns, and automatically requests reviews from the right people or teams.
Think of it as a routing table for code reviews. Instead of manually tagging reviewers and hoping you remembered who owns what, the file does it for you.
GitHub looks for the CODEOWNERS file in three locations, in this order:
.github/CODEOWNERSCODEOWNERS(repository root)docs/CODEOWNERS
It uses the first one it finds and ignores the rest. Most teams put it in .github/ to keep the repo root clean — that’s what I recommend.
Syntax Basics
The format is dead simple. Each line is a file pattern followed by one or more owners:
# This is a comment
PATTERN @owner1 @owner2
Owners can be:
| Format | Example | What it matches |
|---|---|---|
| GitHub username | @user | Individual user |
| GitHub team | @my-org/infra-team | Organization team |
| Email address | ops@example.com | User by email |
Here’s a real-world example from an infrastructure repo:
# Default: infra team reviews everything unless overridden below
* @my-org/infra-team
# Terraform modules — platform team owns these
/terraform/ @my-org/platform-team
# Ansible roles — automation team
/ansible/ @my-org/automation-team
# CI/CD pipelines — DevOps leads
/.github/workflows/ @user @my-org/devops-leads
# Documentation — anyone on the team can approve
/docs/ @my-org/engineering
That’s it. Five lines, and every PR gets routed to the right reviewer automatically.
Pattern Matching: The Rules
CODEOWNERS uses the same pattern syntax as .gitignore, with a few quirks worth knowing.
Common Patterns
# Match everything in the repo
*
# Match a specific file
/Makefile @my-org/devops-leads
# Match all files in a directory (and subdirectories)
/terraform/ @my-org/platform-team
# Match files by extension anywhere in the repo
*.tf @my-org/platform-team
*.yml @my-org/automation-team
# Match a specific subdirectory
/src/api/ @my-org/backend-team
# Match with single-level wildcard
/terraform/modules/*/ @my-org/module-maintainers
# Match with recursive wildcard
/terraform/**/outputs.tf @my-org/platform-team
The Golden Rule: Last Match Wins
This is the single most important thing to understand about CODEOWNERS. If multiple patterns match the same file, the last matching pattern in the file takes precedence.
# This broad rule matches everything
* @my-org/infra-team
# This specific rule overrides the above for Terraform files
/terraform/ @my-org/platform-team
# This even more specific rule overrides both for the networking module
/terraform/modules/networking/ @jsmith
If someone edits /terraform/modules/networking/main.tf, only @jsmith gets the review request — not @my-org/platform-team, not @my-org/infra-team. The last matching line wins, period.
This means order matters. Put your broadest rules at the top and your most specific overrides at the bottom. Think of it like CSS specificity, but simpler — it’s just line order.
Resetting Ownership (The Empty Owner Trick)
Here’s a pattern that trips people up. You can specify a path with no owner to explicitly remove ownership:
# Default: infra team reviews everything
* @my-org/infra-team
# But documentation changes don't need infra review
/docs/
That empty owner line means changes to /docs/ won’t trigger any automatic review request. Anyone with write access can approve. This is useful for low-risk areas where you don’t want to bottleneck the team.
Integrating with Branch Protection
A CODEOWNERS file by itself just requests reviews. If you want to require code owner approval before merging, you need branch protection rules.
Setting It Up
- Go to your repo’s Settings > Branches (or Settings > Rules > Rulesets for newer repos)
- Edit or create a branch protection rule for
main - Enable Require a pull request before merging
- Check Require review from Code Owners
Now a PR that touches files owned by @my-org/platform-team literally cannot merge until someone on that team approves it. This is where CODEOWNERS goes from “nice to have” to “critical infrastructure.”
Branch-Specific CODEOWNERS
Each branch can have its own CODEOWNERS file. GitHub reads the file from the base branch of the pull request (usually main). This means:
- The CODEOWNERS file on
maingoverns who reviews PRs targetingmain - You can have stricter ownership on
productionbranches and looser rules ondevelop - Changes to the CODEOWNERS file itself in a PR won’t take effect until they’re merged to the base branch
That last point catches people. If you add yourself as an owner in your own PR, it doesn’t count until that change lands on the base branch.
Patterns for DevOps and Infrastructure Repos
Here’s a more complete CODEOWNERS file I’ve used in production infrastructure repositories:
# ============================================
# CODEOWNERS — infrastructure monorepo
# ============================================
# Last-match-wins: broad rules first, specific overrides last.
# Default owner for everything
* @my-org/infra-team
# --- Terraform ---
/terraform/ @my-org/platform-team
/terraform/modules/ @my-org/platform-team @my-org/module-reviewers
/terraform/environments/prod/ @my-org/platform-leads
# --- Ansible ---
/ansible/ @my-org/automation-team
/ansible/roles/security/ @my-org/security-team
# --- CI/CD Pipelines ---
/.github/workflows/ @my-org/devops-leads
/.github/actions/ @my-org/devops-leads
# --- Kubernetes manifests ---
/k8s/ @my-org/k8s-team
/k8s/production/ @my-org/k8s-leads @my-org/sre-team
# --- Monitoring and alerting ---
/monitoring/ @my-org/sre-team
# --- Documentation (no required reviewer) ---
/docs/
# --- Sensitive files: require senior review ---
/terraform/environments/prod/*.tfvars @my-org/platform-leads @cto
/.github/CODEOWNERS @my-org/platform-leads
A few things to notice:
- Production paths get stricter reviewers. The
/terraform/environments/prod/path requires platform leads, not just anyone on the platform team. - The CODEOWNERS file itself is owned. This prevents someone from quietly removing themselves from ownership.
- Security-sensitive roles have dedicated reviewers. The security team owns
ansible/roles/security/regardless of who generally owns Ansible. - Documentation has no owner. Low friction for docs means docs actually get written.
Hands-On Lab: Set Up CODEOWNERS
Let’s walk through setting up CODEOWNERS from scratch.
Step 1: Create a test repo
mkdir codeowners-lab && cd codeowners-lab
git init
echo "# CODEOWNERS Lab" > README.md
git add README.md && git commit -m "chore: init"
Step 2: Create the CODEOWNERS file
mkdir -p .github
cat > .github/CODEOWNERS <<'EOF'
# Default reviewers
* @your-username
# Terraform files
/terraform/ @your-username
# Documentation — open to anyone
/docs/
EOF
git add .github/CODEOWNERS
git commit -m "chore: add CODEOWNERS file"
Step 3: Add some files to test patterns
mkdir -p terraform/modules docs
echo 'resource "aws_s3_bucket" "example" {}' > terraform/modules/main.tf
echo "# Architecture Decisions" > docs/architecture.md
echo "name: CI" > .github/workflows/ci.yml
git add -A && git commit -m "feat: add sample project files"
Step 4: Push and verify on GitHub
# Create a repo on GitHub (requires gh CLI)
gh repo create codeowners-lab --private --source=. --push
# Open the CODEOWNERS file in your browser — GitHub will highlight syntax errors
gh browse -- .github/CODEOWNERS
Step 5: Test with a pull request
git checkout -b test/codeowners-verification
echo 'resource "aws_instance" "test" {}' >> terraform/modules/main.tf
git add terraform/modules/main.tf
git commit -m "feat: add test instance"
git push -u origin test/codeowners-verification
# Create a PR and watch the automatic reviewer assignment
gh pr create --title "Test CODEOWNERS routing" \
--body "Verifying that CODEOWNERS assigns the correct reviewer."
Check the PR on GitHub. You should see your username automatically requested as a reviewer based on the /terraform/ pattern.
Step 6: Enable branch protection (optional)
# Require code owner review on main
gh api repos/{owner}/{repo}/branches/main/protection \
--method PUT \
--input - <<'JSON'
{
"required_pull_request_reviews": {
"require_code_owner_reviews": true,
"required_approving_review_count": 1
},
"enforce_admins": false,
"required_status_checks": null,
"restrictions": null
}
JSON
Now PRs touching owned files must be approved by the designated code owner before merging.
Best Practices
After running CODEOWNERS across multiple repos for a couple of years, here’s what I’ve learned:
Use teams, not individuals. People leave, change roles, go on vacation. @my-org/platform-team keeps working when someone is out. @jsmith creates a bottleneck when John is on PTO.
Keep at least two people per ownership group. A team of one is just an individual with extra steps. Make sure every owned path has at least two people who can approve.
Own the CODEOWNERS file itself. Add an explicit rule for /.github/CODEOWNERS so changes to ownership require approval from a lead or admin. Without this, anyone can quietly reassign ownership.
Review your CODEOWNERS quarterly. Teams reorganize. Repos evolve. That path someone owned six months ago might not even exist anymore. Stale rules create confusion.
Don’t over-specify. If your CODEOWNERS file is longer than your actual codebase, you’ve gone too far. Start broad and add specific overrides only where you’ve had real problems with wrong reviewers.
Keep the file under 3 MB. GitHub silently ignores CODEOWNERS files that exceed this limit. Not a concern for most repos, but monorepos with auto-generated ownership rules can hit it.
Troubleshooting Guide
| Problem | Cause | Fix |
|---|---|---|
| Reviewers not being assigned | CODEOWNERS not on the base branch | Merge your CODEOWNERS changes to main first |
| Syntax errors highlighted in GitHub | Invalid pattern or username | Check for typos; verify users/teams have write access |
| Wrong reviewer assigned | Last-match-wins ordering issue | Move specific rules below general rules |
| Team not getting requests | Team lacks write access to the repo | Grant the team write (or maintain) permissions |
| File ignored entirely | CODEOWNERS exceeds 3 MB | Consolidate patterns with wildcards |
| Code owner review not required | Branch protection not configured | Enable “Require review from Code Owners” in branch settings |
| Invalid user/team on a line | User left org or team was renamed | Update the file; use teams to avoid single points of failure |
| CODEOWNERS in wrong location | Multiple files in different locations | GitHub uses the first found: .github/ > root > docs/ — pick one |
| Changes to CODEOWNERS in PR not taking effect | GitHub reads from the base branch | The new rules apply after the PR merges to the base branch |
Quick Reference
# Validate your CODEOWNERS syntax (GitHub highlights errors in the UI)
# Navigate to: github.com/<owner>/<repo>/blob/main/.github/CODEOWNERS
# Check for CODEOWNERS errors via API
gh api repos/{owner}/{repo}/codeowners/errors
# Common patterns
* # Everything
*.tf # All Terraform files
/src/ # Everything under /src/
/src/api/**/*.go # All Go files under /src/api/ recursively
/terraform/modules/*/ # Direct subdirectories of modules
Happy automating!