What maintainers check when they review a pull request to frappe/frappe. For reviewers, contributors and AI agents.
Contributor-side rules live in the Pull Request Checklist and Coding Standards.
Check these first. Ask for the missing piece before reading the code.
- UI changes have before/after screenshots or a video. Ask again after every UI push.
- The bug reproduces on latest develop with the steps in the PR. If there are no steps, ask for a traceback or a minimal repro. If it cannot be reproduced, close for now.
- Branch is rebased with no merge commits or unrelated commits.
- Title and commits follow Conventional Commits.
- Description says what was broken, why, what changed and how to test, and matches the final diff.
- Read the other reviewers' comments and the linked issue before writing your own.
- Does this belong in the framework? App-specific fields, doctypes, roles or conditions belong in the app. The framework provides the hook; the app provides the behaviour.
- Non-framework features (print backends, spam filters, third-party integrations) go in a separate app.
- A new framework API ships together with at least one real use inside Frappe. No is temporary, yes is forever.
- Ask what problem the PR solves before judging how it solves it. Feature work and large refactors need agreement first.
- No new setting for behaviour one app or one user wants. Ship the sane default, or make it per-DocType through Customize Form. Make it configurable only when users would legitimately disagree.
- Do not extend surfaces the project is moving away from.
- The size of the change matches the size of the problem. Extend the existing feature rather than build a parallel one.
- A fix that makes an error disappear without explaining why it happened is not a fix. Find where the bad state comes from and fix it there, not at one call site.
- Deleting a guard, condition or method to make a symptom go away is not a fix. The check was protecting something.
- Read the commit that introduced the behaviour before changing it. Odd code is often intentional.
- Validation goes in
validate,on_trashoron_update, not in one whitelisted caller. Bulk actions, the REST API anddoc.save()skip a check that lives in one endpoint. - If the same bug exists in a sibling (JS and Python, DocField and Custom Field, form and list and print), the fix is incomplete.
- Before adding a field, flag, parameter, endpoint or patch, check whether an existing one already expresses the same thing. Two mechanisms for one meaning will disagree.
- Reuse
frappe.utils,frappe.ui, the database API and the standard library before writing a helper. One implementation per piece of logic. - Prefer the smallest correct change. Plain functions and
ifblocks; no inheritance towers, monkey-patching or global state unless proven necessary. - Leftover code is a blocking finding: unused parameters, unreachable branches, no-op CSS, flags nothing reads, commented-out code.
- Functions take named arguments, not dict or kwargs bags. State goes on
doc.flags, notfrappe.flags. A function returns one shape. - No hardcoded doctype names,
Administratorchecks, hosts or paths. Read hooks, defaults and config. - Every new branch handles
None, duplicates, submitted documents and deleted doctypes, and a new guard must not block the path the user takes to fix it.
- Break established behaviour only when the cost of not breaking it is clearly higher. List who depends on it across frappe, erpnext, hrms and the other apps.
- Public functions, paths and parameters get a
deprecation_warning, one major for apps to migrate, then removal. - Defaults never flip. New behaviour is opt-in.
- Never repurpose or retype a field. Add a new one, migrate, hide the old one. New arguments go last with a default.
- Exports and report columns are an API.
- A new constraint or validation that existing rows would violate blocks
bench migrate. Ship a patch or drop the constraint. - Breaking changes use
fix!:orfeat!:, stay on develop, and land after the dependent app PR. - Do not make a common workflow harder for regular users. Put the burden on system managers.
- Business logic and validation live on the server. The REST API, data import and server scripts never run client code.
ignore_permissions=Trueand blanket role grants are not fixes. Usecheck_permission,has_permissionandonly_for, and give the user the right permission.- Every whitelisted method checks permission before reading and takes identity from
frappe.session.user, never from the client. Guest and security-critical endpoints restrictmethods. Secrets are stored hashed and never returned. - Escape at render time, per context. Never sanitise at storage; stripping loses data.
- A security claim needs a demonstrated bypass. Harden once in the shared layer, not per input. But any path that lets a normal user gain admin is blocked whatever the UX cost.
- Fail loudly. No blanket
try/except, nolog_errorwithout a traceback, catch the specific exception. - Never destroy data silently. Disable instead of delete, confirm bulk actions, no
ELSE NULLin update queries.
- Nothing lands in the hot path for everyone. Anything that runs on every request, save or desk boot is opt-in or free. Read from
frappe.bootinstead of calling the server on page load. - Think in 100k rows. No
get_docin loops, no unindexed filters, no full-table sorts, no unbounded fetches. - Work over a few seconds, third-party calls and bulk operations go to a background job. Patches use bulk updates and commit in batches.
- Performance changes come with numbers: profiler output,
EXPLAIN, or bundle deltas, measured as a real user. No cache without a measured cost it removes.
- Use the highest-level API that does the job: ORM, then query builder, never hand-written SQL. Do not bypass
delete_docand controller hooks withfrappe.db.delete. - No
commit()in document events. Side effects that must survive runafter_commit. - Convert inputs into query builder objects; never regex-check or rewrite generated SQL. MariaDB is the reference; DB-specific code lives in
frappe/database/<db>/. Sorts that feed pagination are deterministic. - Pick the right cache and prove invalidation.
site_cacheis per process and never invalidated. - DocType JSON is changed through the UI, never by hand, and committed with a bumped
modified. Patches only when data actually needs to move.
- Any CSS property that has a token (colour, font size and weight, spacing, radius, shadow, border, z-index) uses the
var(--*)token, never a literal value. Reuse existing CSS classes; delete rules with no user. No inline styles or!important. - Use
es-buttonandes-badge, icons fromfrappe.utils.icon(), gray accents. New controls follow the sibling control. - Defaults serve non-technical users. Labels say what happens, in short plain words, with no internal terms like "DocType".
- Bundle size is a budget. Features used by a minority load on first use.
- A button and dialog on the existing doctype beats a new doctype. A permission beats a setting.
- Confirm destructive actions. Only one primary button is visible on a page or dialog; secondary actions are not primary buttons. Keyboard keeps working. Any change marks the form dirty. Console stays clean.
- Client work is scoped to the instance, runs once, and cleans up its listeners. Client checks also work when the value is set programmatically.
- Comments explain why, never what. Keep one for a non-obvious workaround. No
console.log,print, commented-out code orvar. - Names say what the thing does. Fieldname mirrors label. Checkbox names are positive.
- Annotate all parameters and the return type, or none. Compare dates with
getdate(), checkboxes withcint.None,""and0are different. - Translate whole sentences with
{0}placeholders. Never build a translatable string from pieces. Do not translate fieldnames, file names or user-authored labels. - No guards for states that cannot happen. Flat functions with early returns.
- Tests never leak into production code. No
if frappe.in_test.
- New behaviour and bug fixes ship with a test that fails without the fix. A test that only checks nothing raised, or asserts on generated SQL, proves nothing.
- Test through public interfaces as a real user, not Administrator. Permission tests use
get_list, notget_all. Fixtures useexample.com. - Flaky tests are fixed, not disabled. Changed behaviour updates the existing tests.
- Any real ask means changes requested. Approve when there is none. "Ready with nits" is only for things you would not actually ask to change.
- Blocking, not a nit: a new crash or uncaught exception, leftover no-op code, the same bug in a sibling file, a missing
modifiedbump, a missing permission check, a breaking change without!and a migration path. - Before reporting a finding, confirm the symbol, string or class you name exists where you say it does. Cite a file and line you actually read.
- Bot findings (Greptile, Copilot, Semgrep) are reproduced and answered with evidence, not dismissed and not repeated blindly.
- Comment on the exact line, with a suggestion block when the fix is a one-liner. The review body is for asks with no line, such as missing tests or a wrong title.
- One concern per comment, in short plain sentences. Say what is wrong and what to do instead. Show code when that is clearer than words.
- Back the ask with the issue, the docs or this guide.