Code Review Best Practices: Write Reviews That Actually Improve Code
Every bug that reaches production was once sitting in a pull request, waiting to be caught. Code review is the last checkpoint between a developer's local machine and real users — and most teams treat it like a rubber stamp. A reviewer skims 400 lines, leaves a few nit-picks about naming, clicks 'approve', and calls it done. Meanwhile a subtle off-by-one error in the billing logic ships on Friday afternoon and quietly charges customers twice all weekend. Code review done right prevents that.
The problem isn't that developers don't care. It's that nobody explicitly teaches how to review code. Syntax is taught. Algorithms are taught. But the craft of reading someone else's code critically, giving feedback that improves the code without crushing the author, and knowing which battles to fight? That's learned the hard way — usually after a production incident that a good review would have caught. Code review is also where junior developers level up fastest, because reading production-grade code written by seniors and asking 'why did they do it this way?' accelerates learning by years.
After reading this article you'll know how to structure a review so you catch real bugs — not just style issues. You'll know what to look for at each stage of a review, how to write feedback that people actually act on, and how to build a team culture where code review is a conversation, not a judgment. There are annotated examples, a checklist you can use tomorrow, and the exact mistakes that turn good developers into reviewers nobody wants to work with.
What You're Actually Looking For: The Review Hierarchy
Most reviewers jump straight to style: variable names, formatting, missing semicolons. That's backwards. Style issues are the least important thing in a code review, and they're also the easiest to automate away with a linter. Reviewing style manually is wasting a human brain on a job a machine does better.
Think of code review as a hierarchy. At the top — most important — is correctness. Does the code do what it claims to do? Are there edge cases where it silently fails? Below that is security. Does this code open an injection vector, log a password, or trust user input it shouldn't? Then comes design: is this change the right solution, or is it the right code for the wrong problem? Only at the bottom of the hierarchy do things like naming and formatting belong.
When you open a pull request, resist the urge to start at line one and read top to bottom. Instead, read the PR description first. Understand the intent. Then look at the tests — they tell you what the author thought the contract was. Then read the code against that contract. You're asking: 'Does this code actually deliver what the description and tests promise, and are there cases the author didn't consider?'
/** * A realistic example of the kind of bug a good code reviewer should catch. * The author's intent is clear. The tests might even pass. But there's a * correctness bug hiding in plain sight. */ public class OrderDiscountService { private static final double LOYALTY_DISCOUNT_RATE = 0.10; // 10% for loyal customers private static final int LOYALTY_THRESHOLD_DAYS = 365; // 1 year to qualify /** * Calculates the final price after applying a loyalty discount. * BUG: uses integer division — if orderTotal is less than 10, * the discount rounds down to 0, silently giving no discount at all. */ public double calculateLoyaltyPrice(double orderTotal, int customerAgeDays) { if (customerAgeDays < LOYALTY_THRESHOLD_DAYS) { // Customer hasn't been with us a year yet — no discount return orderTotal; } // REVIEWER SHOULD CATCH THIS: casting to int before multiplying // truncates the discount for small orders, e.g. $9.99 * 0.10 = $0.999, // cast to int = 0, so discount becomes $0.00 int discountAmount = (int) (orderTotal * LOYALTY_DISCOUNT_RATE); return orderTotal - discountAmount; } /** * FIXED version a reviewer would suggest: * Keep the calculation in floating point throughout. */ public double calculateLoyaltyPriceFixed(double orderTotal, int customerAgeDays) { if (customerAgeDays < LOYALTY_THRESHOLD_DAYS) { return orderTotal; } // Stay in double — no silent truncation double discountAmount = orderTotal * LOYALTY_DISCOUNT_RATE; return orderTotal - discountAmount; } public static void main(String[] args) { OrderDiscountService service = new OrderDiscountService(); double smallOrder = 9.50; // A customer buys a $9.50 item int loyalCustomerAge = 400; // They've been with us 400 days — qualifies double buggyResult = service.calculateLoyaltyPrice(smallOrder, loyalCustomerAge); double fixedResult = service.calculateLoyaltyPriceFixed(smallOrder, loyalCustomerAge); // Show the reviewer the cost of missing this bug System.out.println("=== Loyalty Discount Comparison ==="); System.out.printf("Order total: $%.2f%n", smallOrder); System.out.printf("Buggy price (int): $%.2f <-- customer overcharged!%n", buggyResult); System.out.printf("Correct price (double):$%.2f <-- proper 10%% discount%n", fixedResult); } }
Order total: $9.50
Buggy price (int): $9.50 <-- customer overcharged!
Correct price (double):$8.55 <-- proper 10% discount
Writing Feedback That Gets Applied, Not Ignored
The quality of your review is not just about what you find — it's about how you say it. A comment that reads 'this is wrong' gets ignored or causes defensiveness. A comment that explains the problem, why it matters, and suggests a path forward gets applied in 20 minutes.
There's a practical framework called NVC (Non-Violent Communication) that engineers have quietly borrowed for code review. The format is: observation, impact, suggestion. 'This loop iterates over all orders on every request' (observation) — 'which will be slow at scale once we have more than a few thousand orders' (impact) — 'could we filter at the database level before returning results?' (suggestion). That one comment teaches, respects the author, and gives them a direction.
Also get in the habit of labelling your comments. A comment that's a blocking correctness issue looks identical in GitHub to a comment that's a minor suggestion you'd apply if you were writing it. Labels like [blocking], [suggestion], [nit], and [question] let the author know what to prioritize and what they can skip without needing to read your mind.
Finally — leave positive comments too. When you see a clever solution to a hard problem, say so. 'Nice use of the strategy pattern here, this is going to make adding new payment providers much easier' takes five seconds to write and builds the kind of trust that makes authors actually want your reviews.
/** * This file simulates a real scenario: a UserRepository that a reviewer * would flag for a performance problem. The comments below demonstrate * the difference between a BAD review comment and a GOOD review comment * on the exact same issue. */ import java.util.List; import java.util.ArrayList; public class ReviewCommentExamples { // Simulated in-memory 'database' of users static List<String> allUsersInDatabase = new ArrayList<>(); static { // Pre-populate with 10,000 fake users to illustrate the scale problem for (int i = 0; i < 10_000; i++) { allUsersInDatabase.add("user_" + i + "@example.com"); } } /** * ORIGINAL CODE (the author's PR): * Finds active users whose email matches a search term. * * BAD review comment: "This is inefficient." * — Why it fails: gives no context, no suggestion, sounds like a criticism of the person * * GOOD review comment: * [blocking] This loads all 10,000+ users into memory on every search request * (observation). At our current growth rate we'll hit OOM errors within a month * (impact). Could we push the LIKE filter into the SQL query so only matching * rows are fetched? Something like: SELECT * FROM users WHERE email LIKE ? * (suggestion). Happy to pair on this if the query gets complex. */ public static List<String> findUsersByEmailTerm_Original(String searchTerm) { // Loads ALL users then filters in Java — bad at scale List<String> allUsers = allUsersInDatabase; // imagine this is a DB call List<String> matchingUsers = new ArrayList<>(); for (String email : allUsers) { if (email.contains(searchTerm)) { // filtering in application memory matchingUsers.add(email); } } return matchingUsers; } /** * IMPROVED CODE (what the reviewer's suggestion leads to): * In a real app the filter moves to SQL. Here we simulate that by * treating it as a pre-filtered list arriving from the DB layer. */ public static List<String> findUsersByEmailTerm_Improved(String searchTerm) { // Simulates: SELECT email FROM users WHERE email LIKE '%searchTerm%' // Only matching rows cross the network — memory stays flat List<String> dbFilteredResults = simulateDatabaseQuery(searchTerm); return dbFilteredResults; // no redundant in-memory loop needed } /** Simulates a parameterized DB query that filters server-side */ private static List<String> simulateDatabaseQuery(String searchTerm) { List<String> results = new ArrayList<>(); for (String email : allUsersInDatabase) { if (email.contains(searchTerm)) { results.add(email); } } return results; } public static void main(String[] args) { String testSearch = "user_42"; long startOriginal = System.nanoTime(); List<String> originalResult = findUsersByEmailTerm_Original(testSearch); long durationOriginal = System.nanoTime() - startOriginal; long startImproved = System.nanoTime(); List<String> improvedResult = findUsersByEmailTerm_Improved(testSearch); long durationImproved = System.nanoTime() - startImproved; System.out.println("Results match: " + originalResult.equals(improvedResult)); System.out.printf("Original approach: %,d ns (all users loaded into memory)%n", durationOriginal); System.out.printf("Improved approach: %,d ns (only matched rows)%n", durationImproved); System.out.println("\nComment style matters as much as the finding itself."); } }
Original approach: 1,243,800 ns (all users loaded into memory)
Improved approach: 892,100 ns (only matched rows)
Comment style matters as much as the finding itself.
The Reviewer's Checklist: What to Look for Before You Approve
A mental checklist prevents the human tendency to declare a review 'done' after finding the first interesting issue. Once your brain spots something, it relaxes — but the second bug is usually still in there. Professional reviewers work through a deliberate checklist every time.
Start with security. Any PR that touches authentication, authorization, file I/O, or database queries deserves extra time. Look for raw string concatenation in queries (SQL injection), unsanitized values in HTML templates (XSS), and hardcoded secrets. These aren't hypothetical — they appear in real PRs every week.
Next, check error handling. Production code fails in ways that local development never reveals. Is every exception either handled gracefully or deliberately re-thrown? Does the code fail open (insecure default) or fail closed? If a downstream service is unreachable, what does the user see?
Then check for test coverage on the specific scenarios you're worried about. Not overall coverage percentage — that metric is routinely gamed. Ask: 'is there a test that would have caught the bug I just spotted?' If not, that's a blocking comment.
Finally, look at the PR size. A PR over 400 changed lines is almost unreviable. Studies from Microsoft Research show that review effectiveness drops sharply above 200-300 lines. If a PR is massive, it's fair — and kind — to ask the author to split it.
import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; /** * This file demonstrates two security issues a reviewer MUST catch: * 1. SQL injection via string concatenation * 2. A sensitive value being logged * * Both patterns appear in real PRs. Both are easy to fix once spotted. */ public class SecurityReviewChecklist { private static final String DB_URL = "jdbc:h2:mem:testdb"; /** * VULNERABLE: builds SQL by concatenating user-supplied input. * * REVIEWER COMMENT: [blocking][security] * Concatenating `username` directly into the query string allows SQL injection. * A username of: ' OR '1'='1 * ...turns this into: SELECT * FROM users WHERE username = '' OR '1'='1' * which returns every user in the table. * Fix: use a PreparedStatement with a parameterized query (see below). */ public static ResultSet findUser_Vulnerable(Connection conn, String username) throws SQLException { // NEVER do this — user controls the query structure String rawQuery = "SELECT * FROM users WHERE username = '" + username + "'"; // Also: logging the raw username leaks PII to your log aggregator System.out.println("[INSECURE LOG] Executing query: " + rawQuery); return conn.createStatement().executeQuery(rawQuery); } /** * SAFE: uses a PreparedStatement — the DB treats the parameter as * data, never as executable SQL, regardless of what it contains. */ public static ResultSet findUser_Safe(Connection conn, String username) throws SQLException { // ? is a bind parameter — the DB driver handles escaping String safeQuery = "SELECT id, username FROM users WHERE username = ?"; PreparedStatement statement = conn.prepareStatement(safeQuery); statement.setString(1, username); // bind user input as data, not SQL // Log intent without exposing the actual input value System.out.println("[SAFE LOG] Querying users table with parameterized lookup"); return statement.executeQuery(); } public static void main(String[] args) throws Exception { // Set up a lightweight in-memory DB to demonstrate the concepts Connection connection = DriverManager.getConnection(DB_URL, "sa", ""); connection.createStatement().execute( "CREATE TABLE users (id INT PRIMARY KEY, username VARCHAR(100))" ); connection.createStatement().execute( "INSERT INTO users VALUES (1, 'alice'), (2, 'bob')" ); String maliciousInput = "' OR '1'='1"; System.out.println("=== Security Review Demo ==="); System.out.println("\n-- Vulnerable approach (do NOT approve) --"); try { ResultSet rs = findUser_Vulnerable(connection, maliciousInput); int rowCount = 0; while (rs.next()) rowCount++; System.out.println("Rows returned by injection attack: " + rowCount + " (expected: 0)"); } catch (SQLException e) { System.out.println("Query error: " + e.getMessage()); } System.out.println("\n-- Safe approach (parameterized) --"); ResultSet safeRs = findUser_Safe(connection, maliciousInput); int safeRowCount = 0; while (safeRs.next()) safeRowCount++; System.out.println("Rows returned with parameterized query: " + safeRowCount + " (correct)"); connection.close(); } }
-- Vulnerable approach (do NOT approve) --
[INSECURE LOG] Executing query: SELECT * FROM users WHERE username = '' OR '1'='1'
Rows returned by injection attack: 2 (expected: 0)
-- Safe approach (parameterized) --
[SAFE LOG] Querying users table with parameterized lookup
Rows returned with parameterized query: 0 (correct)
Building a Team Code Review Culture That Doesn't Burn People Out
The technical side of code review is the easy part. The hard part is making it sustainable. Teams burn out on reviews when PRs sit for days, when reviewers write essays on every PR, or when every review feels like a performance evaluation.
Three process habits fix most of this. First, set a response SLA. Agree as a team that a PR gets a first review within one business day. This removes the uncertainty that causes authors to chase reviewers on Slack. Most review delays aren't about workload — they're about nobody knowing whose job it is right now.
Second, separate author and reviewer responsibilities clearly. The author is responsible for writing a clear PR description that explains the what and why, linking to the relevant ticket, and keeping the PR small enough to review. The reviewer is responsible for timely feedback, clear labels, and approving once blocking issues are resolved — not perfecting the code to their personal standard.
Third, rotate reviewers deliberately. If the same senior engineer reviews every PR, you get a bottleneck and you deprive junior engineers of the learning opportunity. Junior engineers reviewing senior code is valuable — they ask 'why?' questions that expose implicit assumptions that should be documented. Build a rotation so everyone reviews and everyone gets reviewed.
/** * This is not runnable code — it's a PR description template formatted as * a Java comment block to fit the article format. * * A good PR description is half the battle. Reviewers approve faster * when they understand intent before they read a single line of code. * * ───────────────────────────────────────────── * WHAT MAKES A GREAT PR DESCRIPTION (Template) * ───────────────────────────────────────────── * * ## What does this PR do? * Adds a loyalty discount calculation to the order checkout flow. * Customers who have been registered for 365+ days receive 10% off. * * ## Why are we doing this? * Addresses customer retention feedback from Q3 survey (Ticket: RET-441). * Loyalty customers were churning because we had no visible benefit to staying. * * ## How does it work? * - New `OrderDiscountService` calculates discount based on account age * - Discount applied in `CheckoutController` before payment is initiated * - Rate and threshold are config-driven (see application.yml change) * * ## What should the reviewer focus on? * - The discount calculation in `calculateLoyaltyPrice()` — I want a sanity * check that the rounding behaviour is correct for small order values * - The new index on `users.created_at` in the migration — is this the * right column to index or should it be a composite index? * * ## Testing * - Unit tests cover: no discount below threshold, exact threshold day, * above threshold, zero-value order, max double order value * - Manual test: created a test account dated 2 years ago, confirmed * 10% applied correctly at checkout * * ## What's NOT in this PR? * Email notification to loyalty customers is out of scope — tracked in RET-445. * ───────────────────────────────────────────── */ public class PullRequestDescriptionTemplate { public static void main(String[] args) { System.out.println("A good PR description answers 5 questions:"); System.out.println("1. WHAT: What does this change?"); System.out.println("2. WHY: What business/technical reason?"); System.out.println("3. HOW: High-level approach (not line-by-line)"); System.out.println("4. FOCUS: What do you specifically want eyes on?"); System.out.println("5. SCOPE: What is deliberately NOT included?"); System.out.println(); System.out.println("Reviewers with good context approve 60%+ faster."); System.out.println("Context reduces nit-picks and increases real bug finds."); } }
1. WHAT: What does this change?
2. WHY: What business/technical reason?
3. HOW: High-level approach (not line-by-line)
4. FOCUS: What do you specifically want eyes on?
5. SCOPE: What is deliberately NOT included?
Reviewers with good context approve 60%+ faster.
Context reduces nit-picks and increases real bug finds.
| Aspect | Ineffective Code Review | Effective Code Review |
|---|---|---|
| Primary focus | Style and formatting | Correctness, security, and design |
| Comment format | Vague: 'this is wrong' | Labelled with observation + impact + suggestion |
| PR size | 500-1000+ lines — hard to review | Under 400 lines, ideally under 200 |
| Review trigger | Whenever the reviewer gets around to it | Within 1 business day SLA agreed by team |
| Who reviews | Only the most senior engineer | Rotated across the team including juniors |
| Blocking vs. optional | Unclear — author guesses | Explicitly labelled [blocking], [nit], [suggestion] |
| PR description | Link to Jira ticket, nothing else | What, why, how, focus areas, and scope |
| Tests checked | Overall coverage % glanced at | Specific tests for each logic branch in the PR |
| Security check | Only if the reviewer remembers | Checklist: queries, auth, logging, user input |
| Positive feedback | Rare or never | Explicitly given when good work is spotted |
🎯 Key Takeaways
- Review in priority order: correctness first, then security, then design, then style — style is at the bottom because a linter should be catching it automatically
- Label every review comment as [blocking], [suggestion], or [nit] — this single habit eliminates the ambiguity that causes most PR friction and delays
- A PR description that answers WHAT, WHY, HOW, FOCUS, and SCOPE makes reviewers 60% faster and dramatically increases the quality of feedback they give
- Review effectiveness drops sharply above 200-300 lines (per Microsoft Research) — splitting large PRs isn't bureaucracy, it's the only way to get real reviews instead of rubber stamps
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Reviewing style instead of substance — Symptom: 80% of your comments are about variable names, spacing, or import order, and the PR still ships with a logic bug — Fix: configure a linter (Checkstyle, ESLint, Prettier) to block the PR on style issues automatically, then spend every human review minute on correctness, security, and design instead
- ✕Mistake 2: Approving a PR you don't understand — Symptom: you can't explain what a function does after reading it, but you click approve anyway to avoid slowing the team down — Fix: 'I don't understand what this does' is a valid and important review comment; if the code isn't clear to a competent reviewer, it's either too complex or missing documentation, and both are real bugs
- ✕Mistake 3: Making every comment blocking when most aren't — Symptom: authors spend hours fixing nits and suggestions they thought were optional, causing resentment and slower PRs — Fix: label every comment as [blocking] (must fix before merge), [suggestion] (take it or leave it), or [nit] (tiny thing, not worth holding the PR); this single habit eliminates most review friction overnight
Interview Questions on This Topic
- QWalk me through how you approach reviewing a pull request — what do you look for first, second, and last, and why in that order?
- QTell me about a time a code review caught a bug that would have been expensive in production. What made the review effective enough to catch it?
- QIf a senior engineer on your team consistently writes PRs that are 800+ lines and difficult to review, how would you handle that conversation without damaging the relationship?
Frequently Asked Questions
How long should a code review take?
A focused review of a well-scoped PR (under 200 lines) takes 20-45 minutes if you work through a mental checklist. Reviews that drag on for hours usually mean the PR is too large or too poorly described — the fix is upstream, not in the review itself. Set a 1 business day SLA for first response so authors aren't blocked waiting.
Should junior developers review senior developers' code?
Absolutely — and it's one of the fastest ways juniors level up. Junior developers ask 'why did you do it this way?' questions that expose undocumented assumptions, and they represent the reader that future maintainers will be. A senior whose code can't be understood by a junior should treat that as a documentation or complexity problem, not a reviewer competence problem.
What's the difference between a code review and a pair programming session?
Pair programming is synchronous — two developers building together in real time, catching design issues before a single line is committed. Code review is asynchronous — reviewing code that's already written, best at catching correctness bugs, security issues, and integration problems. They complement each other: pairing reduces review burden, and reviews catch what pairing misses when one developer is tired or over-familiar with their own code.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.