Formatting — Practice Tasks¶
12 hands-on exercises on code formatting and the tooling that enforces it. Each task gives a scenario, a poorly-formatted snippet or a tooling situation (Go / Java / Python — varied), an instruction, then a collapsible solution with the fixed code or config and the reasoning behind it. Difficulty climbs from "reformat this paragraph" to "migrate a 200k-line repo without poisoning
git blame."
Table of Contents¶
- Task 1 — Vertical distance: move declarations to first use (Java) · Easy
- Task 2 — Keep related functions adjacent (Python) · Easy
- Task 3 — Break an over-long line idiomatically (Go) · Easy
- Task 4 — Remove magic vertical whitespace (Python) · Easy
- Task 5 — Fix import ordering and grouping (Go) · Medium
- Task 6 — Write an
.editorconfigfor a polyglot repo (Tooling) · Medium - Task 7 — Add a pre-commit format hook (Tooling) · Medium
- Task 8 — Configure a CI format check (Tooling) · Medium
- Task 9 — Newspaper layout: order a file top-down (Java) · Medium
- Task 10 — Split a 1000-line file by responsibility (Python) · Hard
- Task 11 — Repo-wide format migration with
.git-blame-ignore-revs(Tooling) · Hard - Task 12 — Formatting audit: name every smell and its fix (Open-ended) · Hard
How to Use¶
Formatting is the one quality dimension you should almost never argue about and almost always automate. These tasks split into two kinds:
- Reformatting tasks train your eye to see distance, density, and ordering — the things a formatter cannot fix because they require understanding meaning (where to declare a variable, which functions belong together, how to break a file apart).
- Tooling tasks train you to make the machine enforce the mechanical rules (indentation, line length, import order) so no human ever reviews them again.
For each task: read the scenario, try it yourself in an editor before opening the solution, then compare. For tooling tasks, actually run the tool — a config that "looks right" but fails to install is worthless. The decision flow below is the mental model the whole chapter builds toward.
Task 1 — Vertical distance: move declarations to first use (Java)¶
Difficulty: Easy
Scenario: A teammate declares every local variable in a C89-style block at the top of the method, then uses them dozens of lines later. You have to scroll up to remember a variable's type and initial value every time you read it.
public Report buildReport(List<Order> orders) {
BigDecimal total;
int count;
Map<String, BigDecimal> byRegion;
Report report;
String header;
header = "Quarterly Sales";
count = orders.size();
total = BigDecimal.ZERO;
for (Order o : orders) {
total = total.add(o.amount());
}
byRegion = new HashMap<>();
for (Order o : orders) {
byRegion.merge(o.region(), o.amount(), BigDecimal::add);
}
report = new Report(header, count, total, byRegion);
return report;
}
Instruction: Reformat so each variable is declared at the point it is first assigned, with final where the value never changes. Do not change behavior.
Solution
public Report buildReport(List<Order> orders) {
final String header = "Quarterly Sales";
final int count = orders.size();
BigDecimal total = BigDecimal.ZERO;
for (Order o : orders) {
total = total.add(o.amount());
}
final Map<String, BigDecimal> byRegion = new HashMap<>();
for (Order o : orders) {
byRegion.merge(o.region(), o.amount(), BigDecimal::add);
}
return new Report(header, count, total, byRegion);
}
Task 2 — Keep related functions adjacent (Python)¶
Difficulty: Easy
Scenario: In a module, a public function calls two helpers, but the helpers were dropped at the top of the file in alphabetical order. To understand publish, you read it, jump up 90 lines to _render, jump up again to _compress, then come back down. The conceptual unit is scattered.
# top of module
def _compress(data: bytes) -> bytes:
return gzip.compress(data, compresslevel=6)
def _render(post: Post) -> bytes:
html = TEMPLATE.format(title=post.title, body=post.body)
return html.encode("utf-8")
# ... 90 unrelated lines ...
def publish(post: Post, bucket: Bucket) -> str:
rendered = _render(post)
payload = _compress(rendered)
key = f"posts/{post.slug}.html.gz"
bucket.put(key, payload)
return key
Instruction: Reorder so the caller and its private helpers form one contiguous block, caller first.
Solution
def publish(post: Post, bucket: Bucket) -> str:
rendered = _render(post)
payload = _compress(rendered)
key = f"posts/{post.slug}.html.gz"
bucket.put(key, payload)
return key
def _render(post: Post) -> bytes:
html = TEMPLATE.format(title=post.title, body=post.body)
return html.encode("utf-8")
def _compress(data: bytes) -> bytes:
return gzip.compress(data, compresslevel=6)
# ... unrelated functions live below, in their own groups ...
Task 3 — Break an over-long line idiomatically (Go)¶
Difficulty: Easy
Scenario: A line in a Go file is 180 characters wide. It forces horizontal scrolling in side-by-side diffs and code review. gofmt does not wrap it for you (Go has no line-length rule), so you must break it by hand — and there is a right and a wrong way.
result, err := client.Do(ctx, &Request{Method: "POST", URL: endpoint, Body: payload, Headers: map[string]string{"Authorization": "Bearer " + token, "Content-Type": "application/json"}, Timeout: 30 * time.Second})
Instruction: Break this into a readable form that gofmt keeps stable (running gofmt must not re-flow it). Vary nothing about behavior.
Solution
**Reasoning:** The fix is not "insert backslashes until it fits" — it is to give the composite literal vertical structure. A trailing comma after the last field in a multi-line composite literal is required by `gofmt` and is what makes the layout *stable*: `gofmt` will keep each field on its own line instead of collapsing them back. Extracting the `Request` into its own variable also separates construction (building the request) from the call (sending it), so each line states one idea. Because Go is column-aligned by `gofmt`, the field names line up automatically once they are on separate lines. The general rule: when a line is too long, look for a structure inside it (a literal, an argument list, a chain) and explode *that*, rather than wrapping mid-expression.Task 4 — Remove magic vertical whitespace (Python)¶
Difficulty: Easy
Scenario: A colleague has read somewhere that "code needs room to breathe" and inserted blank lines between nearly every statement, including inside tightly-coupled sequences. The function is now 40 lines tall but does the work of 15.
def normalize_user(raw):
user = {}
user["id"] = raw["id"]
user["email"] = raw["email"].strip().lower()
first = raw.get("first_name", "").strip()
last = raw.get("last_name", "").strip()
user["name"] = f"{first} {last}".strip()
user["active"] = bool(raw.get("active", True))
return user
Instruction: Use blank lines deliberately — to separate groups of related statements — and remove the rest.
Solution
**Reasoning:** Blank lines are punctuation, not decoration. A blank line says "a new thought starts here." When every statement is separated by a blank line, the separators carry no information — the reader cannot tell which statements form a group because *everything* is spaced apart equally. Here there are exactly three thoughts: build the core identity, derive the display name, set the flag and return. One blank line between each makes that structure visible. The identity fields were also merged into a single dict literal because they are one assignment, not three. Inside a coherent group there should be *no* blank lines; between groups there should be exactly one.Task 5 — Fix import ordering and grouping (Go)¶
Difficulty: Medium
Scenario: A Go file has imports in a single jumbled block — standard library, third-party, and internal packages all mixed, some unused, the order arbitrary. goimports exists precisely to fix this, but you should also understand the convention it enforces.
import (
"github.com/acme/shop/internal/cart"
"fmt"
"encoding/json"
"github.com/redis/go-redis/v9"
"github.com/acme/shop/internal/catalog"
"context"
"strings"
)
Instruction: Reorganize into the idiomatic three-group form. State the command that does this automatically.
Solution
Run it automatically with: **Reasoning:** The Go community convention (enforced by `goimports`) is three alphabetically-sorted groups separated by blank lines: standard library, then third-party, then your own module's packages. The grouping encodes dependency layering — a reader instantly sees "this file pulls in one external dependency (Redis) and two of our own packages." The `-local` flag tells `goimports` which prefix is "ours" so internal packages sort into the third group instead of mixing with third-party ones. `goimports` also *removes unused imports and adds missing ones*, so it does more than `gofmt`; this is why most Go shops run `goimports` in their format step rather than `gofmt` alone. Doing this by hand is fine to learn the rule, but in practice you never touch import blocks manually — the tool owns them.Task 6 — Write an .editorconfig for a polyglot repo (Tooling)¶
Difficulty: Medium
Scenario: A repository contains Go, Python, JavaScript, Makefiles, and YAML. Contributors use VS Code, GoLand, Vim, and Neovim. Pull requests keep arriving with mixed tabs/spaces and trailing whitespace because everyone's editor defaults differ. You want every editor to agree on the basics before a formatter ever runs.
Instruction: Write a .editorconfig at the repo root that sets sane, language-aware defaults. Explain why this is the first line of defense, not the last.
Solution
`.editorconfig`:# EditorConfig is the single source of truth for editor defaults.
# Docs: https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
# Go uses tabs by convention (gofmt enforces this).
[*.go]
indent_style = tab
# Makefiles REQUIRE real tabs for recipe lines.
[Makefile]
indent_style = tab
[*.{js,jsx,ts,tsx,json,yml,yaml}]
indent_size = 2
# Markdown: trailing whitespace can be a hard line break, so don't strip it.
[*.md]
trim_trailing_whitespace = false
Task 7 — Add a pre-commit format hook (Tooling)¶
Difficulty: Medium
Scenario: Even with .editorconfig, unformatted Python keeps landing on main because not everyone runs the formatter before committing. You want commits to be auto-formatted (or rejected) locally, so the CI format check almost never fails.
Instruction: Configure the pre-commit framework to run Black and Ruff on every commit. Provide the config and the install commands.
Solution
`.pre-commit-config.yaml`:# Run hooks against staged files on every `git commit`.
# Install once with: pre-commit install
repos:
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
# Lint and auto-fix what is safe (includes import sorting).
- id: ruff
args: [--fix]
# Ruff's formatter — keep it after the linter.
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
Task 8 — Configure a CI format check (Tooling)¶
Difficulty: Medium
Scenario: Local hooks can be skipped with --no-verify. You need an authoritative gate: the build fails if any file is not formatted, with no exceptions and no human judgment involved. This is for a Go service on GitHub Actions.
Instruction: Write a GitHub Actions job that fails when code is not gofmt-clean, printing exactly which files are wrong. Do not auto-fix in CI — only verify.
Solution
`.github/workflows/format.yml`:name: format
on: [push, pull_request]
jobs:
gofmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Check formatting
run: |
# gofmt -l lists files that are NOT formatted. Empty output == clean.
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "$unformatted"
echo "Run: gofmt -w ."
exit 1
fi
Task 9 — Newspaper layout: order a file top-down (Java)¶
Difficulty: Medium
Scenario: A small class compiles fine but reads backwards: low-level helpers are at the top and the public entry point is buried at the bottom. A new reader has to scroll to the end to find where the story even begins.
class CsvExporter {
private String escape(String field) {
if (field.contains(",") || field.contains("\"")) {
return "\"" + field.replace("\"", "\"\"") + "\"";
}
return field;
}
private String row(Record r) {
return String.join(",", escape(r.name()), escape(r.email()));
}
private String header() {
return "name,email";
}
public String export(List<Record> records) {
StringBuilder sb = new StringBuilder(header()).append("\n");
for (Record r : records) {
sb.append(row(r)).append("\n");
}
return sb.toString();
}
}
Instruction: Reorder the methods into newspaper layout — the high-level public method first, then helpers in the order they are called, most detailed last.
Solution
class CsvExporter {
public String export(List<Record> records) {
StringBuilder sb = new StringBuilder(header()).append("\n");
for (Record r : records) {
sb.append(row(r)).append("\n");
}
return sb.toString();
}
private String header() {
return "name,email";
}
private String row(Record r) {
return String.join(",", escape(r.name()), escape(r.email()));
}
private String escape(String field) {
if (field.contains(",") || field.contains("\"")) {
return "\"" + field.replace("\"", "\"\"") + "\"";
}
return field;
}
}
Task 10 — Split a 1000-line file by responsibility (Python)¶
Difficulty: Hard
Scenario: billing.py has grown to ~1000 lines. It contains data models, tax-rate lookups, invoice rendering, a Stripe gateway client, and a CLI entry point — five unrelated reasons to change, all in one file. Every merge conflicts; every reader is lost. You need to split it by responsibility into a package.
Current shape (abridged):
# billing.py (~1000 lines)
import dataclasses, datetime, decimal, json, sys
import stripe # third-party
# --- data models (~120 lines) ---
@dataclasses.dataclass
class LineItem: ...
@dataclasses.dataclass
class Invoice: ...
# --- tax tables and lookup (~200 lines) ---
US_STATE_RATES = { ... }
def tax_rate_for(state: str) -> decimal.Decimal: ...
# --- rendering to HTML/PDF (~250 lines) ---
def render_invoice_html(inv: Invoice) -> str: ...
def render_invoice_pdf(inv: Invoice, path: str) -> None: ...
# --- payment gateway (~300 lines) ---
def charge(invoice: Invoice, token: str) -> str: ...
def refund(charge_id: str, amount) -> None: ...
# --- CLI (~130 lines) ---
def main(argv): ...
if __name__ == "__main__":
main(sys.argv)
Instruction: Propose the target package layout and show the resulting import graph. State the principle that decides where each thing lands, and one concrete pitfall to avoid during the split.
Solution
Target layout — turn the module into a package, one file per reason-to-change:billing/
├── __init__.py # re-exports the public API: Invoice, LineItem, charge, ...
├── models.py # LineItem, Invoice (data, no behavior dependencies)
├── tax.py # US_STATE_RATES, tax_rate_for
├── rendering.py # render_invoice_html, render_invoice_pdf
├── gateway.py # charge, refund (wraps stripe)
└── cli.py # main(argv); the only file that imports sys/argparse
Task 11 — Repo-wide format migration with .git-blame-ignore-revs (Tooling)¶
Difficulty: Hard
Scenario: A 200k-line Python repo has never had a formatter. You're introducing Black. Running black . will touch nearly every file in one giant commit — which would make that commit the "last author" of every line in git blame, destroying the history that tells you why code exists. You must do the migration without poisoning blame.
Instruction: Lay out the full migration: the formatting commit, the .git-blame-ignore-revs file, the git config that wires it up, and the CI/hook safeguards that keep the repo formatted afterward.
Solution
**Step 1 — Make a single, isolated, formatting-only commit.** Nothing but formatting in it.git switch -c chore/format-with-black
black .
git add -A
git commit -m "style: format entire repo with Black (no behavior change)"
Task 12 — Formatting audit: name every smell and its fix (Open-ended)¶
Difficulty: Hard
Scenario: Below is a real-looking Python file fragment. List every formatting smell you can find and write a one-line fix for each. This rehearses reading code for layout the way you'd read it in review.
import os, sys, json
from .models import Invoice
import requests
#==============================================================
# INVOICE SERVICE
#==============================================================
class InvoiceService:
def __init__(self,db,cache,mailer,logger,retries,timeout,base_url):
self.db=db; self.cache=cache; self.mailer=mailer
self.logger=logger; self.retries=retries
self.timeout=timeout; self.base_url=base_url
def send(self, invoice):
# ----- validate -----
if not invoice: return
# ----- build -----
url = self.base_url + "/invoices/" + str(invoice.id) + "/send?notify=true&format=pdf©_to_billing=true&include_history=false"
# ----- send -----
r = requests.post(url, timeout=self.timeout)
return r.status_code
# def old_send(self, invoice):
# url = self.base_url + "/v1/send"
# return requests.post(url).status_code
Instruction: Produce a table of smell → location → fix, then state which fixes a formatter does automatically and which require a human.
Solution
| Smell | Location | Fix | |---|---|---| | Multiple imports on one line | `import os, sys, json` | One import per line; let `ruff`/`isort` sort and group them (stdlib, third-party, local). | | Import ordering / grouping wrong | top block | `requests` (third-party) should sit between stdlib and local `.models`, with blank-line groups. | | Excess blank lines | 3 lines before the banner, 3 after `requests.post` | Collapse to a single blank line between groups; Black caps module-level gaps at 2 and in-function gaps at 1. | | ASCII-art banner comment | `#====` block | Delete it; the `class InvoiceService:` line already names the section. Banners rot and add nothing. | | Magic whitespace in name | `INVOICE SERVICE` (double space) | Irrelevant once the banner is deleted; never encode emphasis in whitespace. | | Missing spaces around operators / after commas | `def __init__(self,db,cache,...)`, `self.db=db` | `db, cache, ...` and `self.db = db`; Black inserts the spaces. | | Multiple statements per line with `;` | `self.db=db; self.cache=cache; ...` | One statement per line; Black splits them. | | Compound statement on one line | `if not invoice: return` | Put `return` on its own indented line; Black expands it. | | Over-long URL line (>120 chars) | the `url = ...` concatenation | Build the query with `urllib.parse.urlencode`/`params=` and an f-string; human decides the idiom. | | Section comments substituting for functions | `# ----- validate/build/send -----` | Extract `_validate`, `_build_url`, `_post` methods; the method names replace the comments. | | Commented-out dead code | `# def old_send...` | Delete it; git history is the archive. | Which the **formatter** fixes automatically (Black + Ruff): one-import-per-line is Ruff, import grouping/sorting is Ruff/isort, excess blank lines, operator/comma spacing, `;`-split statements, and `if x: return` expansion are all Black. Which require a **human**: deleting the banner and the commented-out `old_send` (a formatter won't delete comments), rewriting the long URL into `urlencode`/`params`, and extracting the `# ----- ... -----` sections into named methods (that is refactoring, which changes structure, not just layout).Self-Assessment¶
Rate yourself on each. You have mastered this chapter when you can answer yes to all of them:
- Task 1–2: Can you spot a variable declared far from its use, or a helper marooned away from its caller, and explain why the distance hurts a reader?
- Task 3: Given an over-long line, do you instinctively look for the structure inside it to explode, rather than wrapping mid-expression?
- Task 4: Can you articulate that a blank line is punctuation — "a new thought" — and that uniform spacing carries zero information?
- Task 5: Do you know the three-group import convention and the one command that enforces it for your language?
- Task 6: Can you write an
.editorconfigfrom memory, including the Go-tabs and Makefile-tabs special cases, and explain why it is the floor and not the whole solution? - Task 7: Can you set up a pinned pre-commit hook and explain why pinning the version prevents cross-machine thrash?
- Task 8: Can you write a CI check that verifies without mutating, and explain why CI must never auto-fix?
- Task 9: Do you order a file top-down (newspaper layout), public entry point first, details descending?
- Task 10: Given a 1000-line file, can you name the responsibilities, draw the acyclic import graph, and split it without breaking the public import path?
- Task 11: Can you run a repo-wide format migration that keeps
git blamemeaningful via an isolated commit and.git-blame-ignore-revs? - Task 12: Can you look at any file and instantly sort its formatting issues into "the tool fixes this" vs "a human must decide"?
If any box is unchecked, revisit that task and try it in a real editor and a real repo — formatting is muscle memory, not trivia.
Related Topics¶
README.md— the positive formatting rules this chapter's tasks exercise.junior.md— beginner-level definitions of vertical and horizontal formatting.find-bug.md— snippets where bad formatting hides a real defect.optimize.md— tightening formatting and layout on working code.../../refactoring/README.md— Extract Method / Extract Class / Move Method, the structural moves behind Tasks 9–12.
In this topic