Introduction
Code review is the single highest-leverage practice for improving software quality and spreading knowledge across a team. A well-executed review catches bugs before they reach production, enforces architectural consistency, cross-trains team members, and builds collective code ownership. This guide covers everything from what to look for in a review to building a psychologically safe review culture.
What to Look For in Every Review
Every code review should evaluate changes across five dimensions. The weight each dimension gets depends on context — a hotfix gets less design scrutiny; a new feature gets more.
Correctness
Does the code do what it claims to do? Trace through the logic for the happy path and every edge case you can imagine. Look for off-by-one errors, race conditions in concurrent code, incorrect assumptions about input data, and missing null or error checks.
// REVIEW COMMENT: This assumes the slice is non-empty. If len(users) == 0,
// this will panic. We should guard against the empty case.
if users[0].Role == "admin" {
// ...
}
// SUGGESTED FIX:
if len(users) > 0 && users[0].Role == "admin" {
// ...
}
Design and Architecture
Does the change fit the system’s architecture? Does it introduce unnecessary coupling? Are abstractions at the right level? Watch for leaky abstractions, god functions doing too much, and changes that fight the existing design rather than extending it.
# BAD REVIEW COMMENT: "this is wrong"
# GOOD REVIEW COMMENT: "Extracting the parsing logic into a separate
# function would make this testable in isolation and reusable if we
# add other input formats. What do you think?"
Security
Review every input path: user-supplied data, file reads, network responses, environment variables. Check for SQL injection, command injection, path traversal, XSS, and hardcoded secrets. Never approve code that introduces a known vulnerability class.
// VULNERABLE: Direct interpolation in SQL query
const query = `SELECT * FROM users WHERE id = '${userId}'`;
// SECURE: Use parameterized queries
const query = 'SELECT * FROM users WHERE id = $1';
db.query(query, [userId]);
Performance
Look for N+1 database queries, unnecessary allocations in hot paths, synchronous blocking in async contexts, and expensive operations inside loops. Not every function needs optimization, but changes to critical paths deserve scrutiny.
# BAD: N+1 query pattern — one query for the list, one per item
users = User.objects.filter(active=True)
for user in users:
print(user.profile.bio) # Hits DB every iteration
# GOOD: Single query with select_related
users = User.objects.filter(active=True).select_related('profile')
for user in users:
print(user.profile.bio)
Style and Maintainability
Style matters less than correctness, but inconsistency creates cognitive friction. Enforce style through automated formatters (Prettier, gofmt, Black) so reviewers don’t waste energy on nitpicks. Focus manual review on naming clarity, comment necessity, and overall readability.
# REVIEW COMMENT: "black and flake8 will flag this. Let's auto-format
# instead of discussing style in review. I've added a pre-commit hook
# config — see the repo's CONTRIBUTING.md."
Review Scope and Size Limits
Small reviews get better results. Research from Google and Microsoft shows that reviewing more than 200-400 lines of code per hour drops effectiveness sharply. A single review exceeding 400 lines has a higher defect density in the reviewed code than unreviewed code — reviewers simply miss things.
| Review Size | Recommended Max | Review Time |
|---|---|---|
| Tiny (1-49 lines) | No limit per session | 5-15 min |
| Small (50-199 lines) | 3-4 per session | 15-30 min |
| Medium (200-399 lines) | 1-2 per session | 30-60 min |
| Large (400+ lines) | Split into multiple PRs | 60+ min |
<!-- Pull request template that enforces size limits -->
## Description
<!-- Why is this change needed? -->
## Type of Change
- [ ] Bug fix (< 100 lines)
- [ ] Feature (< 400 lines)
- [ ] Refactor (< 400 lines)
- [ ] Large change (split into stacked PRs please)
## Checklist
- [ ] Added tests for all new logic
- [ ] Updated documentation
- [ ] No new warnings from linter/formatter
- [ ] Each commit is a logical unit
- [ ] Commit messages follow conventional commits
Reviewer Responsibility
Reviewers are the last line of defense before production. Your job is not to rubber-stamp or to nitpick — it’s to verify that the change is correct, safe, and maintainable.
- Review within SLA: Respond within 24 hours on business days. Blocking a colleague’s work for days erodes team velocity.
- Read the full diff: Don’t skip tests, config files, or documentation changes.
- Ask, don’t tell: Phrase comments as questions when you’re unsure. “Is there a reason this uses a linear search instead of a map?” invites discussion.
- Separate blockers from style: Prefix blocking comments with
BLOCKER:so the author knows what must change before merge. - Approve or request changes: Don’t leave a review in limbo. If everything looks good, approve.
<!-- Example of a structured review comment -->
**File:** src/services/payment.go:142
**BLOCKER:** The `charge()` method doesn't handle the `409 Conflict`
response from Stripe. When a charge is retried, Stripe returns 409
with an existing charge ID. We need to catch this and return the
existing charge instead of propagating the error.
**Suggestion:** Wrap the HTTP call and check for `409` before
returning the error. See Stripe's idempotency docs for the expected
response shape.
**Severity:** High — this will cause duplicate charges in production
if the payment service restarts mid-request.
Author Responsibility
Authors set the tone for a productive review. A sloppy PR wastes everyone’s time.
- Write a clear description: Explain what the change does, why it exists, and how to test it.
- Keep PRs focused: One logical change per PR. Don’t sneak in refactors, formatting, or unrelated fixes.
- Self-review first: Read your own diff before requesting reviewers. You’ll catch half the issues yourself.
- Respond graciously: When a reviewer points out an issue, fix it or explain your reasoning. Don’t get defensive.
# Example of a well-structured PR description
## Summary
Replaces the in-memory session store with Redis-backed storage to
support horizontal scaling across multiple app instances.
## Motivation
The in-memory store causes session inconsistency when traffic routes
to different pods. This has been the root cause of 3+ Sev-2 incidents
this quarter.
## Design
- Uses go-redis v9 with connection pooling
- Sessions expire after 24h (TTL matched to existing JWT expiry)
- Falls back to in-memory if Redis is unavailable (graceful degradation)
## Testing
- Unit tests for store operations with a mock Redis client
- Integration test against a local Redis container
- Manually verified session persistence across two app instances
## Related Issues
Closes #342
Review Workflow
GitHub
GitHub’s pull request workflow center around inline comments, review requests, status checks, and branch protection rules.
# .github/workflows/review-checks.yml
name: Code Review Checks
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx eslint src/
- run: npx prettier --check src/
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test -- --coverage
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
GitLab
GitLab merge requests support similar features with integrated CI/CD through .gitlab-ci.yml. GitLab’s “approval rules” let you require specific reviewers for specific file paths — useful for enforcing architecture reviews on core modules.
# .gitlab-ci.yml snippet
code-review:
stage: test
script:
- npm ci
- npm run lint
- npm run typecheck
- npm test
only:
- merge_requests
Automated Review Tooling
Automation handles the mechanical parts of review so humans focus on design, correctness, and security.
Static Analysis
| Tool | Language | What It Catches |
|---|---|---|
| SonarQube | Multi-language | Bugs, vulnerabilities, code smells, tech debt |
| CodeClimate | Multi-language | Maintainability, test coverage, duplication |
| ESLint | JavaScript/TypeScript | Style, anti-patterns, potential bugs |
| Ruff | Python | Style, linting (replaces Flake8 + isort) |
| golangci-lint | Go | Unused code, race detection, style |
| Brakeman | Ruby on Rails | Security vulnerabilities |
# Pre-commit hook configuration (.pre-commit-config.yaml)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-added-large-files
args: ['--maxkb=500']
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
CI-Enforced Gates
Automated checks should run on every PR and block merge on failure. Never let human reviewers waste time on something a machine can catch.
# Example of a merge gate policy
# Block merge unless:
# 1. All CI checks pass
# 2. At least one approval from a senior engineer
# 3. No unresolved review threads
# 4. Branch is up to date with target
Review Speed and Throughput
Review latency is the hidden tax on team velocity. A PR that sits unreviewed for three days blocks the author from starting their next task (context switching cost) or forces them to work in a separate branch that will conflict.
| Team Size | Review SLA | Max Open PRs per Dev |
|---|---|---|
| 2-5 | < 4 hours | 1 |
| 5-15 | < 24 hours | 2 |
| 15+ | < 48 hours | 3 |
Strategies to maintain velocity:
- Rotate reviewer duty: Designate one person per day as primary reviewer.
- Pair on complex changes: A 30-minute synchronous review replaces hours of async back-and-forth.
- Cap WIP per developer: No one should have more than 2-3 open PRs at once.
- Use stacked PRs: Break large features into small, reviewable PRs that build on each other.
Psychological Safety in Reviews
The best review process fails if people are afraid to participate. Psychological safety is the shared belief that you won’t be punished or humiliated for pointing out problems or admitting mistakes.
Do
- Assume good intent. The author did their best with the information they had.
- Use “I” statements: “I found this hard to follow” instead of “This is confusing.”
- Praise good patterns. “Nice use of the strategy pattern here — makes this extensible.”
- Admit your own mistakes. “I actually introduced this same bug last month in the auth module.”
<!-- Psychological safe review language examples -->
INSTEAD OF: "This code is wrong."
USE: "I think there's an edge case here. If the token expires between
the validation check and the data fetch, we'll serve stale data."
INSTEAD OF: "Why did you do it this way?"
USE: "Can you walk me through the design choice here? I want to
understand the trade-offs you considered."
INSTEAD OF: "Fix this."
USE: "The error message leaks internal state. Let's use a generic
message and log the details server-side."
Don’t
- Don’t review while frustrated. Walk away and come back.
- Don’t use sarcasm or personal language. “This is what I’d expect from a junior” has no place in a review.
- Don’t pile on. If three people already pointed out the same issue, approve or leave a +1 instead of repeating.
- Don’t gatekeep with personal preference. “I would have used a switch statement” is not a review finding unless there’s a correctness or maintainability reason.
Review Checklists by Language and Framework
Go Checklist
## Go Code Review Checklist
- [ ] `err` is always checked (no `_ =` for errors)
- [ ] Context is propagated through the call chain
- [ ] Goroutines are tracked (WaitGroup or errgroup)
- [ ] No global state mutation in tests
- [ ] Exported functions have doc comments
- [ ] No naked `return` in functions with named returns
- [ ] Interface definitions are minimal (1-3 methods)
- [ ] `defer` is used for resource cleanup
- [ ] No `init()` functions unless absolutely necessary
- [ ] Benchmarks exist for performance-critical paths
Python Checklist
## Python Code Review Checklist
- [ ] Type hints are present on all new functions
- [ ] Context managers (`with` statements) used for resources
- [ ] No mutable default arguments (e.g., `def f(x=[])`)
- [ ] Exception handling is specific (no bare `except:`)
- [ ] Logging uses structured fields, not string formatting
- [ ] Database queries use parameterized inputs (no f-strings)
- [ ] Async functions use proper await patterns (no blocking calls)
- [ ] Test fixtures are scoped appropriately (function, class, module)
- [ ] No star imports (`from module import *`)
- [ ] Functions are < 50 lines (LSP rule)
JavaScript / TypeScript Checklist
## JavaScript/TypeScript Code Review Checklist
- [ ] Strict mode enabled (TypeScript: `strict: true`)
- [ ] No `any` type unless unavoidable (and justified in comment)
- [ ] Async/await used over raw Promise chains
- [ ] No `console.log` in production code
- [ ] React hooks follow rules of hooks (no conditional hooks)
- [ ] useEffect has proper dependency array (no lint-disable)
- [ ] Bundle size impact considered for new dependencies
- [ ] Event listeners are cleaned up in useEffect return
- [ ] API error responses are handled, not swallowed
- [ ] No direct DOM manipulation (use React refs)
Security Checklist
## Security Code Review Checklist
- [ ] All user input is validated (type, length, format, range)
- [ ] SQL queries use parameterized statements or ORM
- [ ] No secrets or tokens in source code or config files
- [ ] Authentication checks exist on every protected endpoint
- [ ] Authorization checks verify ownership/role, not just auth
- [ ] File uploads validate type and size server-side
- [ ] Rate limiting is applied to mutation endpoints
- [ ] CORS is configured per-origin, not wildcard
- [ ] Output is encoded/escaped to prevent XSS
- [ ] Logging does not include PII or secrets
Comparison Table: Code Review Tools
| Feature | GitHub | GitLab | Bitbucket | Gerrit | Review Board |
|---|---|---|---|---|---|
| Inline comments | Yes | Yes | Yes | Yes | Yes |
| CI/CD integration | Actions | Built-in | Pipelines | Plugin | Plugin |
| Merge checks | Branch rules | Approval rules | Merge checks | Verified labels | Custom |
| Code owners | CODEOWNERS | Code Owners | CODEOWNERS | Plugin | No |
| Stacked PRs | Stack PR API | Merge trains | No | Built-in (patch sets) | No |
| Self-hosted | GHES | Self-managed | Data Center | Yes | Yes |
| Free plan limits | Unlimited public | Unlimited users | 5 users | Unlimited | Unlimited |
| CLI support | gh CLI | glab CLI | No | git-review | RBTools |
| SAST integration | Code scanning | Secret Detection | No | Plugin | Plugin |
Commit Message Conventions
Consistent commit messages make review history navigable and enable automated changelog generation.
# Conventional Commits format
<type>(<scope>): <description>
[optional body]
[optional footer]
# Examples
feat(auth): add OAuth2 token refresh flow
fix(api): handle null pointer in user deserialization
docs(readme): update deployment instructions
refactor(payments): extract discount calculation to Strategy pattern
chore(deps): upgrade go-redis to v9.4.0
Use the --no-verify flag sparingly — it exists for emergencies, not for skipping pre-commit checks on lazy days.
Conclusion
Code review is a skill, not a checkbox. The best reviewers ask good questions, respect the author’s time by reviewing promptly, and maintain a tone that makes their team better. Invest in automation for the mechanical parts, invest in culture for the human parts, and invest in yourself by learning what makes a review truly useful.
Resources
- Google’s Code Review Developer Guide
- GitHub Pull Request Documentation
- GitLab Merge Request Documentation
- Conventional Commits Specification
- SonarQube Static Analysis
- Stripe Engineering Blog — Code Review
- The Art of Code Review (Clubhouse Blog)
- pre-commit — Framework for Git Hook Scripts
- Developer Productivity Tools
- Open Source Alternatives
- Developer Communities
Comments