Quality Code Review
A reviewer's checklist for Frappe applications. Protect correctness, security,
and the future maintainer, in that order of consequence. The sections below run
in that order — spend most attention on §1 and §2. Prefer a root-cause fix over a
workaround, and say why a finding matters (what breaks, for whom).
1. Correctness & stability (highest consequence)
The worst bug is silent stateful corruption — wrong ledgers/stock posted
with no error. Treat stateful and legal/accounting/compliance code as "failure
is not an option" code.
- Fail early and loudly. Use assertions for internal invariants:
assert total_credit == total_debit. (Assertions are for invariants the code
guarantees — not user-facing validation.)
- Picture how it breaks. For every change ask: Where can this break? How
will someone misuse this? Write fool-proof code. Extensions and overrides
should especially consider handling all sorts of failure modes.
- No partial commits. A stray
frappe.db.commit() / db.rollback()
mid-transaction ends the transaction and exposes partial state — flag every
one. Submitting/saving with validation bypassed (docs posted with no SL/GL
entry when validation fails) is a critical bug.
- Don't sacrifice atomicity for convenience (e.g. adding
autocommit to fix
bootstrapping makes transactions non-atomic). Autocommit belongs only on schema
creation.
- Preserve invariants over UX. "Compromise UX, but guarantee correctness."
Don't degrade working code to accommodate broken code.
- Validate the issue before fixing it. Sometimes the correct fix is "don't
fix this". Identify the root causes first.
- Watch for destructive DB APIs with empty/
None filters.
set_value("Site", None, ...) / db.delete with no filter updates/deletes
every row. These must error, not silently operate on the whole table. Flag
any set_value/delete/get_value where the name/filter could be None or
empty- or attacker-controlled.
- Check the types in a condition actually match. A comparison between
mismatched types (string vs
datetime, string vs int) silently never matches
or is always true — cast explicitly (cint/flt) at the boundary.
- Question the return shape. Before indexing a result, ask whether it can be
None / [None, None]; watch as_dict=1 (list of dicts) vs scalar confusion.
- Don't silently change long-standing semantics. Behavior callers have relied
on for a long time is a contract — altering it is a breaking change in disguise,
even when no signature changed.
2. Security
Avoiding a vulnerability is far easier than fixing one safely. Audit
security-critical code (auth, authorization, permissions, user management)
especially hard.
Injection
- NEVER build SQL by string concatenation/f-strings. Use the ORM or query
builder. If raw SQL is unavoidable, use parameter substitution
(
frappe.db.sql("... where name = %s", (user,))) — never interpolate yourself.
Better still, avoid introducing new raw SQL at all: beyond injection risk it
ties code to one database, and the framework aims to stay DB-agnostic (Postgres
support). Prefer frappe.qb.
- Type confusion is an injection vector even with the ORM. Frappe accepts
complex types, so a parameter expected to be a string can arrive as a filter
list:
{"key": ["!=", ""]} passed to db.get_value bypasses a secret-key
check. Validate input types at trust boundaries — explicit
isinstance(key, str). Audit every @frappe.whitelist method for this.
- Never
eval/exec anything yourself. safe_eval/safe_exec only, in limited
volume, and "safe_exec is not magic." Never accept a client-supplied method
path to execute.
Sandboxing & trust boundaries
- Sandboxed execution (RestrictedPython/
safe_exec) is not reliably safe —
assume escapes exist. Security toggles must live at the right trust
boundary: server-script enablement is a bench-level config, never
site-level (a tenant could enable it and take over the whole server).
- Prefer allowlists over blocklists — blocklists are bypassable. Don't
expose everything by default.
Access control
- "Think 10 times before
allow_guest=True" — it is not a shortcut around real
authn/authz. Web pages must apply permissions before reading/sharing data.
Prefer get_list/get_all over hand-rolled queries.
- Scope relaxations precisely. Verify a rate-limit/permission exception
targets exactly the intended principal — not, say, all non-guest users.
Path traversal / filesystem
- Prefer the File doctype API. If user input enters a path, ensure it can't
traverse (
/../../) outside the site folder.
Crypto / secrets
- Never roll your own crypto; reuse existing implementations. Verify authenticity
of guest/webhook requests (HMAC).
- Signed/one-time URLs: use a truly secret signing value; expire by both time
and first use; validate using the URL alone, not merged form data (Frappe
merges URL + form data → replay attacks with one valid signature).
- Store secrets in password fields; never plain text; never leak secrets in logs
or error messages.
XSS & the rest of OWASP
- Don't inject user input into the DOM. Treat XSS as critical even when it looks
trivial — HTML/JS injection usually leads to account hijack.
- Don't fix XSS by sanitizing and throwing away special characters. Prefer escaping right before injecting values in DOM.
3. Performance is correctness
The cheapest time to fix performance is at review; slow code merged sits
undetected for years. Performance is a feature (Doherty threshold; humans
perceive ~100ms).
- Budgets: common reads < 100ms; reads < 1s; most writes < 5s; never
exceed ~10s. P99 of a frequent read-only request should be ~1s. A slow
synchronous request blocks a worker (head-of-line blocking).
- Complexity rule: a frequently-called endpoint must do O(1) or O(log N)
work — a large constant factor at worst, never O(N). Counting rows is O(N), not
O(1);
COUNT(*) over a large/filtered table is expensive. Bound unbounded
scans (e.g. last 3 months, a "1000+" sentinel) rather than scanning everything.
- Indexes are code. Flag any
WHERE/join/filter on an unindexed column.
Indexes (and custom indexes) must be committed in code, not applied ad-hoc
— they get lost on migration otherwise. Form loads that pull
comments/versions/assignments need all those queries indexed; one unindexed
query makes everything sluggish.
- No DB calls in loops. "Don't write validations that call db in LOOPS." Flag
N+1 patterns. Cache stable values (UOM, docstatus, status) instead of
re-querying. This is acceptable in background jobs, but never in requests.
- The Remove → Reduce → Reuse ladder for slow code you can't fix: remove it,
invoke it less, or memoize. Pick the right cache scope (
@redis_cache, @request_cache, @site_cache — the last balloons memory if
overused). DO NOT hand-roll caches in frappe.local or frappe.flags:
"you'll just be creating brand-new cache-invalidation bugs." Don't cache
trivially cheap work.
- Memory: don't stuff junk into shared module-level files /
__init__.py /
class-level state — it stays resident forever. Remove unused module-level
imports (move into the function that uses them). Watch for leaks.
- Reorder conditionals so the DB call is last. In a boolean expression, put
cheap in-memory checks first so short-circuiting can skip the query entirely.
- Aggregate in SQL, not Python. Use
SUM()/COUNT() in the query instead of
fetching all rows to reduce them in memory; push filters into the subquery so
they apply before the join.
- Don't fetch a whole doc for one value. Use
get_value/get_single_value/
set_value for a single column instead of get_doc().save(); use
frappe.delete_doc instead of get_doc().delete() (which fetches the doc only
to delete it).
- No MyISAM tables in hot paths. Reading a MyISAM table takes an implicit
table-level lock — never touch one in a request path.
- Move long work to a background queue. Long-running work belongs in
enqueue(..., queue="long"), not a synchronous request that blocks a worker.
4. Concurrency
- Check-then-act is a race.
if not frappe.db.exists(...): insert() — two
workers both see "not exists" and both insert. Prefer a DB-level unique
constraint; "outsource integrity to the database."
- Locking footguns:
SELECT ... FOR UPDATE on an unindexed query locks every
scanned row (and gaps) — always ensure the filter uses an index, or you lock
the whole table. Locking a parent but not its children yields a "mutant" doc.
- Global mutable state / class attributes are global in Python — a shared
engine/class attribute leaking query state across concurrent requests produces
garbage. Make query-building stateless. Don't do "weird shit with
frappe.local" — local is for variables, not static state.
5. Readability & maintainability
~50% of dev time is spent reading code; rotten code eventually forces a rewrite.
- Keep functions pure when they can be pure — easy to read and test.
- Don't pass mutable objects around to be filled in ("assembly" code) —
return new values. Passing a mutable to be mutated forces a reader to open two
files to understand one thing.
- A function that mutates its input must be named appropriately.
- Prefer the boring construct. While that functional map-reduce one-liner
looks beautiful, please just write a 4-line for-loop. Favor debuggable code
over clever code.
- Consistency over personal style. A codebase shouldn't be a hodge-podge of
10 styles. Match the surrounding formatting/naming/import conventions; flag a
change that breaks them.
- Good taste: restructure so the edge case becomes the common case, removing
special-case branches. Ask: can this be simpler? Less code? Is it
over-indented if-else soup?
- Prefer extending shared components over copy-paste divergence. 3–4 forked
implementations of one thing → slow long-term velocity. Avoid tight coupling
across modules; integrate through clear, documented public APIs.
- Document public modules/classes/functions with docstrings; prefer type
annotations over describing types in prose ("type hints are 10x better");
type checkers find non-obvious bugs.
- Docstrings should only mention important things. Keep them short and to the
point. Don't explain what's trivially understood from function name. Focus on
"why".
- Split unrelated changes into separate commits/PRs — keeps review focused
and
git blame/reverts clean.
6. API design & backward compatibility
- Principle of least astonishment: an API's name + signature should convey
~90% of intent; users shouldn't be surprised by behavior.
- Reject loose/overloaded parameters that accept many disjoint types
(string/dict/list/None). Prefer separate single-purpose functions. Beware
implicit fallbacks; use explicit variants. "APIs whose correct use depends on
tribal knowledge are a liability."
- Build for extension, not override. Provide hooks; never monkey-patch
core at runtime ("inexcusably horrible" — breaks future fixes) and never copy a
whole core file to change a few lines (fixes won't propagate).
- Backward compatibility is an obligation for mature/public APIs. Follow
semver; minor versions = zero breaking changes. Breaking changes include:
removing public functions/fields, reordering args, new mandatory args, changed
business logic, moved/renamed files (broken imports), bumped shared deps.
Renaming without keeping the old name as an alias is an unnecessary break.
Every breaking change ships a deprecation warning + docs.
- Watch for schema breaking-change footguns: adding mandatory fields to
existing sites, making long-lived fields unique (needs a data patch), changing
field types without patches, removing fields.
- Schema changes that silently skip existing sites need a data patch. Single
doctypes don't sync new-field defaults to existing sites, and a field-type
change (e.g. text→int) doesn't convert existing values — both need an explicit
patch, tested against a populated site.
- New parameters go last as keyword args with safe defaults (
None, not
"") so existing positional callers don't break. When renaming, keep the old
name as a shim: def old_name(...): return new_name(...).
- Patch hygiene. Data patches must be idempotent (safe to re-run),
correctly ordered (run after the field/doctype they read exists), and live
in the right app (a framework change is patched in the framework, not the
downstream app).
- A modified existing test is a red flag. If making a change pass required
editing an existing test's assertions, you've likely broken a real workflow —
justify it explicitly rather than bending the test.
7. Testing
- Each PR needs decent test coverage — patch coverage on the diff, not just
project coverage. (Frappe target: 85% covered lines in the diff.) Tests should
capture the most-used business scenarios.
- Regression test every fix. A bug fix without a test that would have caught
it invites the regression back. For extreme-consequence (stateful/compliance)
code, go beyond examples — property-based testing (Hypothesis).
- Flag missing migration/data-patch coverage. Schema changes and data patches
are the highest-risk, least-tested area; a change that alters fields or
migrates data needs a patch tested against a realistic, populated site (empty
tables always "migrate" successfully even when the change is invalid).
- Tests must be deterministic and independent. No
random (flaky); no
reliance on state left by other tests (order-dependence); use freeze_time
for time-dependent logic.
8. Error messages, logging & observability
- Error message quality is a legitimate review item. Titles must be specific
and Google-able (never "Message"/"Error"). Reference field names as fields.
State what changed: the row, the field, and before→after values
(
1 → 2). The user must know "qty changed from what to what?"
- Surface failures to the affected party — "a broken email setup is the
user's problem only if they know it's broken."
- Log things. Preserve tracebacks/exception context (orders of magnitude
easier debugging). Log destructive/admin actions with attributable identity
(who, when, from where), persisted outside ephemeral containers.
1---2name: quality-code-review3description: Review code for any Frappe application — a checklist distilled from years of engineering practice on correctness, security, performance, concurrency, readability, API design, and testing. Use this when reviewing a diff, a PR, or a piece of code for quality and security, or when you want a reviewer's checklist grounded in hard-won Frappe/ERPNext lessons.4---5
6# Quality Code Review
7
8A reviewer's checklist for Frappe applications. Protect **correctness, security,
9and the future maintainer**, in that order of consequence. The sections below run
10in that order — spend most attention on §1 and §2. Prefer a root-cause fix over a
11workaround, and say *why* a finding matters (what breaks, for whom).
12
13---
14
15## 1. Correctness & stability (highest consequence)
16
17The worst bug is **silent stateful corruption** — wrong ledgers/stock posted
18with no error. Treat stateful and legal/accounting/compliance code as **"failure
19is not an option"** code.
20
21- **Fail early and loudly.** Use assertions for internal invariants:
22 `assert total_credit == total_debit`. (Assertions are for invariants the code
23 guarantees — not user-facing validation.)
24- **Picture how it breaks.** For every change ask: *Where can this break? How
25 will someone misuse this?* Write fool-proof code. Extensions and overrides
26 should especially consider handling all sorts of failure modes.
27- **No partial commits.** A stray `frappe.db.commit()` / `db.rollback()`
28 mid-transaction ends the transaction and exposes partial state — flag every
29 one. Submitting/saving with validation bypassed (docs posted with no SL/GL
30 entry when validation fails) is a critical bug.
31- **Don't sacrifice atomicity for convenience** (e.g. adding `autocommit` to fix
32 bootstrapping makes transactions non-atomic). Autocommit belongs only on schema
33 creation.
34- **Preserve invariants over UX.** "Compromise UX, but guarantee correctness."
35 Don't degrade working code to accommodate broken code.
36- **Validate the issue before fixing it.** Sometimes the correct fix is "don't
37 fix this". Identify the root causes first.
38- **Watch for destructive DB APIs with empty/`None` filters.**
39 `set_value("Site", None, ...)` / `db.delete` with no filter updates/deletes
40 *every* row. These must error, not silently operate on the whole table. Flag
41 any `set_value`/`delete`/`get_value` where the name/filter could be `None` or
42 empty- or attacker-controlled.
43- **Check the types in a condition actually match.** A comparison between
44 mismatched types (string vs `datetime`, string vs int) silently never matches
45 or is always true — cast explicitly (`cint`/`flt`) at the boundary.
46- **Question the return shape.** Before indexing a result, ask whether it can be
47 `None` / `[None, None]`; watch `as_dict=1` (list of dicts) vs scalar confusion.
48- **Don't silently change long-standing semantics.** Behavior callers have relied
49 on for a long time is a contract — altering it is a breaking change in disguise,
50 even when no signature changed.
51
52## 2. Security
53
54Avoiding a vulnerability is far easier than fixing one safely. Audit
55security-critical code (auth, authorization, permissions, user management)
56especially hard.
57
58**Injection**
59- NEVER build SQL by string concatenation/f-strings. Use the ORM or query
60 builder. If raw SQL is unavoidable, use parameter substitution
61 (`frappe.db.sql("... where name = %s", (user,))`) — never interpolate yourself.
62 Better still, **avoid introducing new raw SQL at all**: beyond injection risk it
63 ties code to one database, and the framework aims to stay DB-agnostic (Postgres
64 support). Prefer `frappe.qb`.
65- **Type confusion is an injection vector even with the ORM.** Frappe accepts
66 complex types, so a parameter expected to be a string can arrive as a filter
67 list: `{"key": ["!=", ""]}` passed to `db.get_value` bypasses a secret-key
68 check. **Validate input *types* at trust boundaries** — explicit
69 `isinstance(key, str)`. Audit every `@frappe.whitelist` method for this.
70- Never `eval`/`exec` anything yourself. `safe_eval`/`safe_exec` only, in limited
71 volume, and "safe_exec is not magic." Never accept a client-supplied method
72 path to execute.
73
74**Sandboxing & trust boundaries**
75- Sandboxed execution (RestrictedPython/`safe_exec`) is not reliably safe —
76 assume escapes exist. Security toggles must live at the **right trust
77 boundary**: server-script enablement is a *bench*-level config, never
78 site-level (a tenant could enable it and take over the whole server).
79- Prefer **allowlists over blocklists** — blocklists are bypassable. Don't
80 expose everything by default.
81
82**Access control**
83- "Think 10 times before `allow_guest=True`" — it is not a shortcut around real
84 authn/authz. Web pages must apply permissions *before* reading/sharing data.
85 Prefer `get_list`/`get_all` over hand-rolled queries.
86- **Scope relaxations precisely.** Verify a rate-limit/permission exception
87 targets exactly the intended principal — not, say, all non-guest users.
88
89**Path traversal / filesystem**
90- Prefer the File doctype API. If user input enters a path, ensure it can't
91 traverse (`/../../`) outside the site folder.
92
93**Crypto / secrets**
94- Never roll your own crypto; reuse existing implementations. Verify authenticity
95 of guest/webhook requests (HMAC).
96- Signed/one-time URLs: use a truly secret signing value; expire by **both** time
97 and first use; **validate using the URL alone, not merged form data** (Frappe
98 merges URL + form data → replay attacks with one valid signature).
99- Store secrets in password fields; never plain text; never leak secrets in logs
100 or error messages.
101
102**XSS & the rest of OWASP**
103- Don't inject user input into the DOM. Treat XSS as critical even when it looks
104 trivial — HTML/JS injection usually leads to account hijack.
105- Don't fix XSS by sanitizing and throwing away special characters. Prefer escaping right before injecting values in DOM.
106
107## 3. Performance is correctness
108
109The cheapest time to fix performance is at review; slow code merged sits
110undetected for years. Performance is a feature (Doherty threshold; humans
111perceive ~100ms).
112
113- **Budgets:** common reads < 100ms; reads < 1s; most writes < 5s; **never**
114 exceed ~10s. P99 of a frequent read-only request should be ~1s. A slow
115 synchronous request blocks a worker (head-of-line blocking).
116- **Complexity rule:** a frequently-called endpoint must do O(1) or O(log N)
117 work — a large constant factor at worst, never O(N). Counting rows is O(N), not
118 O(1); `COUNT(*)` over a large/filtered table is expensive. Bound unbounded
119 scans (e.g. last 3 months, a "1000+" sentinel) rather than scanning everything.
120- **Indexes are code.** Flag any `WHERE`/join/filter on an unindexed column.
121 Indexes (and custom indexes) must be **committed in code**, not applied ad-hoc
122 — they get lost on migration otherwise. Form loads that pull
123 comments/versions/assignments need *all* those queries indexed; one unindexed
124 query makes everything sluggish.
125- **No DB calls in loops.** "Don't write validations that call db in LOOPS." Flag
126 N+1 patterns. Cache stable values (UOM, docstatus, status) instead of
127 re-querying. This is acceptable in background jobs, but never in requests.
128- **The Remove → Reduce → Reuse ladder** for slow code you can't fix: remove it,
129 invoke it less, or memoize. Pick the right cache scope (
130 `@redis_cache`, `@request_cache`, `@site_cache` — the last balloons memory if
131 overused). DO NOT hand-roll caches in `frappe.local` or `frappe.flags`:
132 "you'll just be creating brand-new cache-invalidation bugs." Don't cache
133 trivially cheap work.
134- **Memory:** don't stuff junk into shared module-level files / `__init__.py` /
135 class-level state — it stays resident forever. Remove unused module-level
136 imports (move into the function that uses them). Watch for leaks.
137- **Reorder conditionals so the DB call is last.** In a boolean expression, put
138 cheap in-memory checks first so short-circuiting can skip the query entirely.
139- **Aggregate in SQL, not Python.** Use `SUM()`/`COUNT()` in the query instead of
140 fetching all rows to reduce them in memory; push filters into the subquery so
141 they apply *before* the join.
142- **Don't fetch a whole doc for one value.** Use `get_value`/`get_single_value`/
143 `set_value` for a single column instead of `get_doc().save()`; use
144 `frappe.delete_doc` instead of `get_doc().delete()` (which fetches the doc only
145 to delete it).
146- **No MyISAM tables in hot paths.** Reading a MyISAM table takes an implicit
147 table-level lock — never touch one in a request path.
148- **Move long work to a background queue.** Long-running work belongs in
149 `enqueue(..., queue="long")`, not a synchronous request that blocks a worker.
150
151## 4. Concurrency
152
153- **Check-then-act is a race.** `if not frappe.db.exists(...): insert()` — two
154 workers both see "not exists" and both insert. Prefer a **DB-level unique
155 constraint**; "outsource integrity to the database."
156- **Locking footguns:** `SELECT ... FOR UPDATE` on an unindexed query locks every
157 scanned row (and gaps) — always ensure the filter uses an index, or you lock
158 the whole table. Locking a parent but not its children yields a "mutant" doc.
159- **Global mutable state / class attributes are global in Python** — a shared
160 engine/class attribute leaking query state across concurrent requests produces
161 garbage. Make query-building **stateless**. Don't do "weird shit with
162 `frappe.local`" — `local` is for variables, not static state.
163
164## 5. Readability & maintainability
165
166~50% of dev time is spent reading code; rotten code eventually forces a rewrite.
167
168- **Keep functions pure when they can be pure** — easy to read and test.
169- **Don't pass mutable objects around to be filled in** ("assembly" code) —
170 return new values. Passing a mutable to be mutated forces a reader to open two
171 files to understand one thing.
172- A function that mutates its input must be named appropriately.
173- **Prefer the boring construct.** While that functional map-reduce one-liner
174 looks beautiful, please just write a 4-line for-loop. Favor debuggable code
175 over clever code.
176- **Consistency over personal style.** A codebase shouldn't be a hodge-podge of
177 10 styles. Match the surrounding formatting/naming/import conventions; flag a
178 change that breaks them.
179- **Good taste:** restructure so the edge case becomes the common case, removing
180 special-case branches. Ask: can this be simpler? Less code? Is it
181 over-indented if-else soup?
182- **Prefer extending shared components over copy-paste divergence.** 3–4 forked
183 implementations of one thing → slow long-term velocity. Avoid tight coupling
184 across modules; integrate through clear, documented public APIs.
185- Document **public** modules/classes/functions with docstrings; **prefer type
186 annotations over describing types in prose** ("type hints are 10x better");
187 type checkers find non-obvious bugs.
188- Docstrings should only mention important things. Keep them short and to the
189 point. Don't explain what's trivially understood from function name. Focus on
190 "why".
191- **Split unrelated changes** into separate commits/PRs — keeps review focused
192 and `git blame`/reverts clean.
193
194## 6. API design & backward compatibility
195
196- **Principle of least astonishment:** an API's name + signature should convey
197 ~90% of intent; users shouldn't be surprised by behavior.
198- **Reject loose/overloaded parameters** that accept many disjoint types
199 (string/dict/list/None). Prefer separate single-purpose functions. Beware
200 implicit fallbacks; use explicit variants. "APIs whose correct use depends on
201 tribal knowledge are a liability."
202- **Build for extension, not override.** Provide hooks; never monkey-patch
203 core at runtime ("inexcusably horrible" — breaks future fixes) and never copy a
204 whole core file to change a few lines (fixes won't propagate).
205- **Backward compatibility is an obligation** for mature/public APIs. Follow
206 semver; **minor versions = zero breaking changes**. Breaking changes include:
207 removing public functions/fields, reordering args, new mandatory args, changed
208 business logic, moved/renamed files (broken imports), bumped shared deps.
209 Renaming without keeping the old name as an alias is an *unnecessary* break.
210 Every breaking change ships a deprecation warning + docs.
211- **Watch for schema breaking-change footguns:** adding mandatory fields to
212 existing sites, making long-lived fields unique (needs a data patch), changing
213 field types without patches, removing fields.
214- **Schema changes that silently skip existing sites need a data patch.** Single
215 doctypes don't sync new-field defaults to existing sites, and a field-type
216 change (e.g. text→int) doesn't convert existing values — both need an explicit
217 patch, tested against a populated site.
218- **New parameters go last as keyword args with safe defaults** (`None`, not
219 `""`) so existing positional callers don't break. When renaming, keep the old
220 name as a shim: `def old_name(...): return new_name(...)`.
221- **Patch hygiene.** Data patches must be **idempotent** (safe to re-run),
222 **correctly ordered** (run after the field/doctype they read exists), and live
223 in the **right app** (a framework change is patched in the framework, not the
224 downstream app).
225- **A modified existing test is a red flag.** If making a change pass required
226 editing an existing test's assertions, you've likely broken a real workflow —
227 justify it explicitly rather than bending the test.
228
229## 7. Testing
230
231- **Each PR needs decent test coverage** — patch coverage on the diff, not just
232 project coverage. (Frappe target: 85% covered lines in the diff.) Tests should
233 capture the most-used business scenarios.
234- **Regression test every fix.** A bug fix without a test that would have caught
235 it invites the regression back. For extreme-consequence (stateful/compliance)
236 code, go beyond examples — property-based testing (Hypothesis).
237- **Flag missing migration/data-patch coverage.** Schema changes and data patches
238 are the highest-risk, least-tested area; a change that alters fields or
239 migrates data needs a patch tested against a realistic, populated site (empty
240 tables always "migrate" successfully even when the change is invalid).
241- **Tests must be deterministic and independent.** No `random` (flaky); no
242 reliance on state left by other tests (order-dependence); use `freeze_time`
243 for time-dependent logic.
244
245## 8. Error messages, logging & observability
246
247- **Error message quality is a legitimate review item.** Titles must be specific
248 and Google-able (never "Message"/"Error"). Reference field names as fields.
249 State *what changed*: the row, the field, and before→after values
250 (`1 → 2`). The user must know "qty changed from what to what?"
251- **Surface failures to the affected party** — "a broken email setup is the
252 user's problem only if they know it's broken."
253- **Log things.** Preserve tracebacks/exception context (orders of magnitude
254 easier debugging). Log destructive/admin actions with attributable identity
255 (who, when, from where), persisted outside ephemeral containers.