Open the Server Script list in ERPNext, click New, and you get a code editor and a Script Type dropdown. Whatever you type there is stored in the database and executed by Frappe through safe_exec, which uses RestrictedPython to limit what the code can reach.
The four types (five on v16)
DocType Event attaches to one DocType and one lifecycle event. The list covers Before Validate, Before Save and After Save, Before and After versions of Insert, Submit, Cancel, Delete and Rename, the two “Submitted Document” save events for fields allowed on submit, and Before Print. You get the document as doc. The labels don’t match controller method names one to one (Before Save runs at validate), which matters when you move a script into code later.
API turns the script into an endpoint under /api/method/<method name>. You can tick Allow Guest for unauthenticated calls and Enable Rate Limit to throttle them, and you return data by setting frappe.response["message"].
Scheduler Event runs on a frequency: Hourly, Daily, Weekly, Monthly, Yearly, the “Long” queue variants, or a Cron expression.
Permission Query adds a condition to list queries for a DocType. Frappe hands you user and reads back whatever you assign to conditions.
Frappe v16 adds a fifth type, Workflow Task.
Turning them on
On a self-hosted bench, Server Scripts are off until you set server_script_enabled. In the v15 and v16 source, Frappe reads that key only from common_site_config.json, so set it bench-wide:
bench set-config -g server_script_enabled 1
If it’s missing, running a script fails with a “Server Scripts Disabled” error. On Frappe Cloud, the docs say you need a private bench; shared public benches don’t allow them.
An example: sales reps see their own orders
# Script Type: Permission Query
# Reference Document Type: Sales Order
is_manager = frappe.db.exists(
"Has Role", {"parent": user, "parenttype": "User", "role": "Sales Manager"}
)
if user != "Administrator" and not is_manager:
conditions = "`tabSales Order`.owner = " + frappe.db.escape(user)
Keep in mind what this does and doesn’t cover. Permission query conditions filter results from frappe.db.get_list, which drives list views and reports. They don’t decide whether someone can open a single document by its URL. That’s the job of role permissions, or a has_permission hook in an app’s hooks.py.
Limits to know before you rely on one
The sandbox has no import, and frappe.db.sql accepts read queries only. Names starting with an underscore are blocked. DocType Event scripts are also skipped while bench migrate or an app install is running, so a rule enforced by a script won’t fire on records a patch creates.
There’s no test runner and no Git history either. For a two-line validation that’s a fair trade. For anything posting to the ledger, it isn’t, and we’ve set out where we draw that line in Server Scripts vs a custom app. erpfly doesn’t generate Server Scripts for business rules. It writes the same logic as Python in a Frappe app, wired through doc_events, so it ships with tests in the pull request.