# Server Scripts vs a custom app in ERPNext: when to use which

Canonical: https://erpfly.com/blog/server-scripts-vs-custom-app-erpnext/
Last updated: September 15, 2026

Published June 23, 2026 in ERPNext. Server Scripts are quick, sandboxed and untestable. A custom Frappe app takes longer to set up. Here's where we draw the line, with code for both.

Use a Server Script for a small, self-contained rule you need today, like blocking a save when a field is empty or setting a value from another field. Use a custom Frappe app for anything that touches money or stock, needs a Python import, calls an external API in more than a trivial way, or that you'd want a test for. Server Scripts run in a restricted sandbox, live in the database and have no tests or history. A custom app is ordinary Python in Git that you can review, test and install on any site.

Most cases fall cleanly on one side. The rest of this post is the detail behind that, because the line isn't always obvious when you're staring at a deadline.

### What a Server Script actually is

A Server Script is a record of the Server Script DocType. You write Python in a code field, pick a type, and Frappe runs it at the right moment. The types are:

- **DocType Event**: runs on a document lifecycle event for one DocType.
- **API**: exposes a method at `/api/method/<name>`, optionally for guests.
- **Scheduler Event**: runs on a schedule (hourly, daily, a cron expression and so on).
- **Permission Query**: returns extra conditions that filter list views for a DocType.

The event names in the UI don't match controller method names one to one, which catches people out. **Before Save** runs at `validate`, **After Save** at `on_update`, and **Before Delete** at `on_trash`. If you later move the logic into an app, map them carefully.

A typical DocType Event script:

```python
# Server Script: DocType Event, Reference DocType: Sales Order, Event: Before Save
if doc.grand_total > 20000 and not doc.custom_approval_reason:
    frappe.throw("Orders over 20,000 need an approval reason")
```

Two lines, no deploy, and it works. That's the appeal, and for a rule this small it's a perfectly good choice.

### Server Scripts are off unless you turn them on

On self-hosted benches, Server Scripts have been disabled by default since v15. If you try to run one on a bench where they're off, Frappe throws an error saying they're disabled and points you at the bench configuration.

The switch is the `server_script_enabled` key, and on v15 and v16 Frappe reads it only from `common_site_config.json`. The source even has a comment saying so. That means it's a bench-wide setting:

```bash
bench set-config -g server_script_enabled 1
bench restart
```

Setting it in one site's `site_config.json` does nothing, which catches people out on multi-tenant benches. If only one site should run scripts, that site belongs on its own bench.

If you're on Frappe Cloud, whether you can enable them depends on your plan and bench type. Check before you design a solution around them.

The fact that they're off by default tells you something. Server Scripts let anyone with the System Manager role run code on your server. The sandbox limits the damage, but it's still arbitrary code written in a browser textarea.

### What the sandbox won't let you do

Server Scripts run through Frappe's `safe_exec`, built on RestrictedPython. You get a curated `frappe` namespace and a handful of helpers. You don't get Python.

The restrictions you'll hit first:

- **No `import` statements.** No `requests`, no `re`, no third-party libraries. Frappe exposes some utilities through `frappe.utils`, and there are `frappe.make_get_request` and `frappe.make_post_request` for simple HTTP calls, but that's the menu.
- **Read-only raw SQL.** `frappe.db.sql` only accepts read queries inside a script. Writes have to go through documents or `frappe.db.set_value`.
- **No underscore attributes.** Names and attributes that start with `_` are blocked, so you can't reach into private internals.
- **No filesystem or process access.** No `open`, no subprocess, no environment variables.
- **Top-level code, not a module.** A DocType Event script body is just executed. You can't `return` early from it, so you end up nesting `if` blocks.

None of this is a bug. It's the sandbox doing its job. But every one of those limits is a reason a Server Script grows ugly workarounds once the requirement stops being tiny.

### The same rule as a custom app

Here's the approval rule from above in a Frappe app. First, hook it to the Sales Order in `hooks.py`:

```python
# acme_custom/hooks.py
doc_events = {
    "Sales Order": {
        "validate": "acme_custom.selling.sales_order.validate_approval_reason",
    }
}
```

Then the function itself:

```python
# acme_custom/selling/sales_order.py
import frappe
from frappe import _
from frappe.utils import flt

APPROVAL_LIMIT = 20000


def validate_approval_reason(doc, method=None):
    if flt(doc.grand_total) > APPROVAL_LIMIT and not doc.get("custom_approval_reason"):
        frappe.throw(_("Orders over {0} need an approval reason").format(APPROVAL_LIMIT))
```

And a test:

```python
# acme_custom/selling/test_sales_order.py
import frappe
from frappe.tests.utils import FrappeTestCase  # on v16: from frappe.tests import IntegrationTestCase

from acme_custom.selling.sales_order import validate_approval_reason


class TestApprovalReason(FrappeTestCase):
    def test_large_order_needs_reason(self):
        doc = frappe._dict(grand_total=25000, custom_approval_reason=None)
        self.assertRaises(frappe.ValidationError, validate_approval_reason, doc)

    def test_reason_given_passes(self):
        doc = frappe._dict(grand_total=25000, custom_approval_reason="Strategic account")
        validate_approval_reason(doc)
```

```bash
bench --site test.localhost run-tests --module acme_custom.selling.test_sales_order
```

It's more files, and you need to deploy it. In exchange, the rule is reviewed in a pull request, tested on every change, translated, and identical on staging and production. The `custom_approval_reason` field ships with the app as a fixture instead of being created by hand on each site.

### Testability

This is the biggest difference, and people underrate it until the first regression.

You can't unit test a Server Script. The only way to check it is to create a document in the UI or through the API and see what happens. That's fine for one rule. It's miserable when twenty scripts on Sales Invoice interact and someone changes one. With an app, `bench run-tests` tells you in minutes whether last week's change broke month-end.

### Version control

Server Scripts live in the database. There's no diff, no blame and no history beyond the document's version log. You can export them as fixtures to get them into Git:

```python
fixtures = [
    {"dt": "Server Script", "filters": [["name", "in", ["Sales Order Approval Reason"]]]},
]
```

It works, but the result is code stored as a string inside JSON. Reviews are painful, and the database copy and the file copy drift the moment someone edits the script in the browser on production. If you're going to that trouble, you're already most of the way to an app.

### Performance

For a validation rule that runs once per save, the difference won't matter. The sandbox adds guard checks around attribute and item access, which makes tight loops slower than the same code in an app. Where you'll feel it is a Scheduler Event script that loops over thousands of records, or an API script called on every page load of a portal. Those belong in an app, where you can also use background jobs properly and profile the code.

The bigger performance problem is usually structural. Scripts can't share helper modules, so logic gets copied between them, and each copy does its own queries.

### Where we draw the line

Our rule of thumb, from cleaning up a fair number of sites:

**A Server Script is fine when** the rule fits on a screen, reads or validates fields on a single document, doesn't post to the ledger or move stock, and you'd be comfortable losing it and rewriting it from memory.

**Move it to an app when** any of these are true:

- It touches GL entries, stock, taxes or payroll.
- It needs a library, a regex, or anything the sandbox blocks.
- It calls an external API with retries, auth tokens or error handling.
- The same logic appears in more than one script.
- It broke once already and went unnoticed for a week.
- You're about to upgrade ERPNext and can't say what the scripts depend on.

Client Scripts follow a similar logic, with one extra rule: never put validation only in a Client Script. The REST API and Data Import skip the browser entirely.

### Moving Server Scripts into an app without drama

When a site has outgrown its scripts, we move them in this order:

1. Export every enabled Server Script and read them. Group by DocType.
2. For each one, write the equivalent function in the app, hooked through `doc_events`, `scheduler_events` or `@frappe.whitelist()`.
3. Write a test for anything touching money.
4. Deploy the app to staging with the scripts **disabled**, not deleted, and run through the real scenarios.
5. Deploy to production, disable the scripts there, and delete them after a full business cycle without surprises.

Don't enable both at once. A rule that runs twice, once from the script and once from the app, produces the kind of bug that takes a day to spot.

This also matters for upgrades. Scripts that reference renamed fields break silently, which we cover in [keeping customizations working through an ERPNext upgrade](https://erpfly.com/blog/erpnext-upgrade-keep-customizations/).

### Letting erpfly write the app version

If the reason you reach for Server Scripts is that setting up an app feels like overhead, that's the part erpfly takes away. Describe the rules in plain English and you get a Frappe app with hooks, fixtures and tests as a pull request. See [ERPNext custom module development](https://erpfly.com/erpnext-custom-module-development/), or [ERPNext customization](https://erpfly.com/erpnext-customization/) for smaller changes. For a sense of what a rules-heavy module looks like, the [sales order management module](https://erpfly.com/modules/sales-order-management/) is a good example.

### Sources

- [Server Script, Frappe Framework documentation](https://docs.frappe.io/framework/user/en/desk/scripting/server-script)
- [safe_exec.py (version-16), Frappe source code](https://github.com/frappe/frappe/blob/version-16/frappe/utils/safe_exec.py)
- [server_script_utils.py (version-15), Frappe source code](https://github.com/frappe/frappe/blob/version-15/frappe/core/doctype/server_script/server_script_utils.py)
- [Client Script, Frappe Framework documentation](https://docs.frappe.io/framework/user/en/desk/scripting/client-script)
- [Testing, Frappe Framework documentation](https://docs.frappe.io/framework/user/en/testing)