How does the Frappe Framework work?

Cover image

If you haven't heard of Frappe (pronounced: fra-pay), you could still follow this post and treat it as any WSGI (pronounced: whiskey) synchronous Python Web Application. You can also read about Frappe on the official site.

What is Frappe?

Frappe is a (low-code) full-stack web application framework written in Python and JavaScript. It is used to build powerful web applications that require configurability on the fly. It powers ERPNext, a popular open-source ERP system among other applications and organisations. It shines in building database-driven applications that require a lot of customisation and flexibility.

[Video Source: Awesome Frappe]

To understand how it works, we need to understand the different components that make up the Frappe Framework. The Frappe Framework is a collection of Python and JavaScript libraries that work together to provide a full-stack web application framework. It is built on top of the Flask web framework and uses MariaDB/PostgreSQL as the database.

It isn't like a lot of other Python web frameworks out there, as it comes with its own

  • Web-based User Interface - Configurable AdminUI with out-of-the-box common views from form to kanban + Configurable User-facing forms & web page views.
  • Role & User Permissions - Enterprise grade granular permission system.
  • Extreme Customizations - that business users can do without writing code, and developers can do with writing code.
  • Templating PDF Generation - Build PDF formats using drag-and-drop & low-code components for your documents.
  • Site based multi-tenancy - share server resources, while maintaining data isolation.
  • WebSockets - Realtime updates for enabling better UX.
  • Web server - Extensible, multi-faceted routing & out-of-box REST + RPC API.
  • ORM & Query Builder - Powerful Document Model & Query Builders.
  • Migrations - Self-managed schema migrations & database transactions.
  • Scheduler & Background Jobs - Extensible & customizable scheduler.

For all your coding & scripting needs, Frappe falls back to Python, JavaScript, HTML, CSS, and SQL as primitives. This means that you don't need to learn a new language to work with Frappe, but you do need to understand how the framework works or get familiar with the API.

Architecture of Frappe Environments

While staying true to the high-level question of how Frappe works, we can look at the architecture of the Frappe Framework. The following diagram gives a high-level overview of the architecture of the Frappe Framework.

Frappe Architecture

This is roughly how the default setup should look like when you install Frappe on a server or local setup - the boundary of the diagram refers to your machine(/computer/VM/server). While you may be familiar with the "Services" section - which refers to persistent and in-memory data stores, the "Runtime Dependencies" refers to system dependencies that Frappe or other (Frappe) Apps depend on.

I omitted frappe from the apps directory in the diagram for simplicity. However, Frappe is a core app that is installed by default and first during setup. I'll expand on this later in the post.

What is a Bench?

The "Bench" section refers to a particular instance of the Frappe Framework, which can host multiple sites, each with its own combination of Apps. You may have multiple benches on a single machine, each with its own combination of Application versions - this also implies that one bench may refer to one specific Frappe version installation.

OK, and what makes a bench? Put simply, it's a directory structure that contains the following:

.
|-- Procfile  # file containing the processes that should be run when the bench is started in development mode
|-- apps  # directory containing Frappe applications that you would install using `bench get-app`
|-- config  # directory containing configuration files for Redis, supervisor, nginx, etc.
|-- env  # Python virtual environment managed by bench
|-- logs  # directory that tracks bench-level logs, tracks commands run, site-agnostic logs, etc.
|-- patches.txt  # file containing patches that should be applied to the bench environment (NOT sites)
`-- sites  # directory containing sites that you would create using `bench new-site`
 |-- furniture.localhost  # is a site directory that contains its own configuration, logs, backups, files, etc.
 `-- photos.localhost

This "bench" can be created using the Bench CLI tool, which is a command-line utility that helps you manage Frappe deployments. I know, the naming is a bit confusing, I wrote a short introduction addressing this in the official Bench docs.

Reproducibility

For the uninitiated readers, Reproducibility is a key aspect of managing software environments. It refers to the ability to recreate an environment with the same configuration and dependencies.

To recreate a bench, you need to keep in mind the following:

  • Apps: Individual App versions (commit SHA or tag)
  • Configuration: common_site_config.json; and if you have customized service configurations: Procfile/supervisor.conf, nginx.conf, redis.conf, etc.
  • System Dependencies: System packages, Python version, Node version, etc.

I find Nix Flakes particularly helpful in managing the system dependencies for my benches. It allows me to define the system dependencies in a single file and reproduce the environment on any machine.

What is a Site?

A "site" refers to a specific website that you would host on a bench (and by extension, your server). Each site has its own configuration, database, files, and logs. The site is the smallest unit of deployment in Frappe, and you can have multiple sites on a single bench (multi-tenancy) promoting better resource utilization.

Reproducibility

To recreate a site, you need to first create the target bench, and the following:

  • Configuration: site_config.json
  • Database: Latest database backup, stored as *.sql.gz under ./sites/cloud.gavv.in/private/backups
  • Files: Latest files backup, stored as *.tar under ./sites/cloud.gavv.in/private/backups
  • Logs: Site-specific logs (Optional)

If you wish to recreate the "site" environment without the data, you can skip the database and file backups and instead focus on the order of installation of the Apps.

What is an App?

An "App" refers to a Frappe application that you can install on a bench. An App can be a standalone application or an extension of the Frappe Framework. Apps can be installed using the bench get-app command, and they can be selectively installed on a per-site basis. The bench new-app command creates the scaffolding for a new app. The App's state is maintained in the apps directory of the bench and tracked by git.

If you build a generic enough workflow, you can share it across sites by packaging it along with an App. This is particularly useful when you want to share customizations or extensions with others. For example, you write a server script that generates a PDF of a Sales Invoice upon submission and emails it to the customer. You may want to re-utilize it for other DocTypes too. Now you also realise that this is a common use case and you want it on every new site you set up. Here's where you can write a script that does this and package it as an App. You could also write apps that override the default behaviour of Frappe or other apps.

You may also feel that Frappe by itself is an App, and that's true. It also places itself alongside other apps in the same directory. Frappe is the conductor who orchestrates and defines functionality for the whole bench. It also self-describes by its own core functionality. We may refer to Frappe as one, or peek under the hood and refer to it as a collection of individual Apps & Batteries that form the monolith.

For a non-exhaustive list of available Frappe apps (and resources), you can check out Awesome Frappe.

Architecture of a Frappe App

A Frappe Application may be of two main types that I have identified:

  • That can be installed on a site: This is the primary class and use-case of applications. Most applications fall under this category. Eg: ERPNext, CRM, etc.
  • That can [only] be installed on a bench: These applications are generally used to extend functionality at a bench level and have no meaning when installed on a site. Use cases may include generating templates for certain Apps during development or providing custom commands for specialized flows Eg: Doppio, ERPNext-Druckformate, etc.

Typically, Frappe follows the App -> Module -> Doctype hierarchy. This means that:

  • An App may be referred to as a collection of modules, with some uniform functionality or purpose. [Each App may contain multiple modules]
  • A Module may contain multiple DocTypes, each representing a different entity or object. [Each Module may contain multiple DocTypes]

I suppose you could go further and say the relationship also extends to DocType -> DocField -> Property:

  • A DocType may contain multiple DocFields, each representing a different field or property. [Each DocType may contain multiple DocFields]
  • Each DocField contains a fixed number of properties. This is where the data is stored. [A DocField is the smallest unit of data storage]

What are Runtimes?

I've used "Runtimes" as a label to refer to different services, each responsible for a specific task:

  • Web Server: Frappe packages a Python-based WSGI Application that is served by Gunicorn [code].
  • Background Workers: Frappe provides an interface to run Python workers that handle background jobs from all the sites on the bench. When you call frappe.enqueue, the function signature of the args, kwargs is recorded in "Redis Queue" and these workers indefinitely keep consuming from the queue and executing code as given user on given site[code].
  • Scheduler: Frappe provides a scheduler that schedules jobs to be run at specific intervals. The scheduler is a long-running process that keeps track of the jobs that need to be run and runs them at the specified time[code-entrypoint], [code-interesting].
  • Realtime Server: A NodeJS-based server is bundled with Frappe that allows for real-time updates to the client. This server manages authentication via websockets, rooms to allow efficient notifications & such [code].

These runtimes are managed by the Bench CLI, and we can peek into the configuration of these services in the ./config directory of the bench. For their orchestration, Bench uses Procfile-based orchestrators via bench start during development and Supervisor in production.

Production vs Development Setup

The diagram above was representative of how a default setup would look like. Depending on your comfort level, you may choose to do things differently, a couple of examples:

  • maintain apps and env in an immutable container while keeping the sites and logs on the host machine
  • use managed services like RDS & ElastiCache for the data stores, or other drop-in replacements for Redis or MariaDB/Postgres, etc

First, let's highlight the main differences between a production and development setup:

Aspect Development Setup Production Setup
Purpose Optimized environment for building reproducible Frappe applications. Designed for hosting applications with stability, performance, and security in mind.
Environment Local or lightweight environments, often using tools like Docker or natively, trigger via bench start and governed by Procfile. Deployed on servers or cloud environments with optimized application containers or natively. Processes are managed by Supervisor
Web Server Uses a development server armed with watchers that hot-reload, making it easier to see changes without restarting the server. NGINX sits in front of web workers managed by Supervisor. Hot reloading is disabled.
Deployment Approach Use standard git-based flows depending on requirements, eg: git pull + bench setup requirements + bench migrate Deployed using best practices, e.g., Frappe Cloud, Docker containers, orchestration tools, or CI/CD pipelines.

While there are more differences, these are the main ones that you should be aware of. The development setup is more suited for building and testing applications, while the production setup is designed for hosting applications with stability, performance, and security in mind.

My Preferred Setup

I've had my share of time spent on debugging issues that could have been avoided with a more controlled setup while maintaining the Bench CLI and being the internal support for the Frappe team's broken benches. Managing multiple versions of Python, Node & other system packages can be a pain when you're supporting multiple major releases (Frappe generally supports the last three [ref]).

What I look for in a setup is reproducibility, ease of maintenance, and performance. Over the years, between managing some dependencies or services natively, containerized or through Nix (here's a ref experimenting something in between).

Windows has been out of the question for me for almost a decade, I had a brief stint with MacOS for a couple of years, but for various reasons - mainly convenience, I prefer Linux-based distributions.

Today, for development I prefer maintaining separate Nix Flakes for each of my benches that maintain pinned system dependencies, and using Docker for database services. For production, I prefer using Docker Compose to manage the services. I've found this setup to be the most reliable and easy to maintain.

tldr; Linux, Nix, Docker, Compose, (and some IaC tools)

I discourage companies/individuals from self-hosting and managing servers unless they have the necessary expertise. Additionally, I refrain from using bench update in production environments and instead opt for a more controlled approach.

How does Frappe work?

We can now delve into how the Frappe Framework works. How does a web application work? A good approach to understanding this is to look at "the lifecycle of a request". For a framework like Frappe, we may even need to look into the lifecycle of jobs and CLI commands too (Am I forgetting about something? πŸ’).

Let's break it down step by step, I'll try not to go too granular at any single level.

Lifecycle of a Request

What happens when you type in the URL of a production Frappe site in your browser and hit enter? Answering this question should give you a good idea of one aspect of how Frappe works.

While we go through the process, it's important to note that although we're highlighting a specific request, the server is handling multiple requests at the same time. And as a user, when you actually visit a site, your browser makes tens of requests to the server to fetch the resources needed to render the page, and does many more things than what we can cover in one blog post.

Sample URL: https://cloud.gavv.in/

URL Parsing

Where this happens: User's Browser

The browser parses the URL and extracts the domain name cloud.gavv.in. It recognises that you are trying to access said domain with the scheme https at path /.

The scheme https tells the browser to use the HTTPS protocol, which is HTTP over SSL/TLS. Given there is no port specified, the default port for HTTPS is 443.

DNS lookup

Where this happens: User's Browser, Operating System, DNS Server (Local network router, Internet Service Provider, Public DNS, etc.)

There are some things that the browser doesnt have inherent knowledge of, knowing where exactly cloud.gavv.in is located on the internet is one of them.

This is where the Domain Name System (DNS) comes into play. Computers only deal in IP addresses, whether IPv4 or IPv6. A DNS lookup is performed to find the IP address of the domain name cloud.gavv.in.

Resolved DNS query: 101.4.31.99

TCP Connection & TLS Handshake

Where this happens: User's Browser, Operating System & Frappe's NGINX

The Transmission Control Protocol (TCP) is a connection-oriented protocol that provides reliable, ordered, and error-checked delivery of a stream of bytes between applications running on hosts communicating over an IP network.

The browser establishes a TCP connection with the server at the IP address obtained from the DNS lookup and default port for SSL connections. If the server is listening on the port, the connection is established else the browser will throw an error.

SSL (Secure Sockets Layer) and its successor TLS (Transport Layer Security) are cryptographic protocols that provide secure communication over a computer network. A TLS handshake is performed to establish a secure connection between the browser and the server. This involves the exchange of certificates and encryption keys. The browser verifies the server's certificate and if everything checks out, the connection is established.

Resolved address: 101.4.31.99:443

Your browser has made first contact with NGINX through the TCP connection. Bench manages NGINX configuration dictating how incoming connections are handled [code].

HTTP Request Building

Where this happens: User's Browser

HyperText Transfer Protocol (HTTP) is an application-layer protocol for transmitting hypermedia documents, such as HTML. It was designed for communication between web browsers and web servers, while today it has become a general-purpose protocol for communication between clients and servers.

The browser constructs an HTTP request with headers, body, method, and other details. The request is sent to the server at the resolved address. Although a modern browser will add a lot more headers, for simplification, we can assume a GET request to look like the following:

GET / HTTP/1.1
Host: cloud.gavv.in
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none

Also, note that the request is encrypted because of the https scheme. The listener at the IP address (ie. server) will have to decrypt the request to read the headers and body.

Reverse Proxy

Where this happens: Frappe's NGINX Server

NGINX acts as a reverse proxy server, handles TLS termination, forwards the request to the appropriate backend server and serves static assets.

In order to handle a given request, NGINX needs to decrypt the request and peek into it first. The first line of the decrypted payload dictates that it's a GET request to / on the domain cloud.gavv.in. In the following lines, a series of headers in the form of Key: Value pairs are expected.

We can restrict our focus on the Host header, which refers to the domain or site you're trying to access. You may have multiple domains pointing to the same site, which will also be translated into the local Frappe site name by NGINX. This information is also propagated to the backend server by adding new headers like X-Frappe-Site-Name.

Now that the site is known, NGINX can infer which Bench the request is intended for. Depending on the path, the request needs to be handled differently:

  • /assets/, /files - Serve static assets from the sites/cloud.gavv.in/public directory
  • /socket.io/ - Forward the request to the SocketIO server running for the respective bench
  • Forward all other requests to the Frappe Web Server running for the respective bench

These rules are defined under ./config/nginx.conf in the bench directory, managed by the Bench CLI [code].

Because the path for our current request is /, the request is forwarded to the Frappe Web Server.

Frappe Web Server

Where this happens: Frappe's Gunicorn or SocketIO Server

Web Server

Synchronous Python-based web applications are typically served using an HTTP server that implements the Web Server Gateway Interface (WSGI) protocol. Frappe uses Gunicorn as the WSGI server. Gunicorn is a Python HTTP server that uses the pre-fork worker model. This means that it creates multiple worker processes to handle incoming requests.

For the special paths not mentioned in the earlier section as the "exceptions" like files, socket.io or assets, the wildcard fallback rule is that "the Frappe web server will handle it". Under that case, Frappe receives a Request object from Gunicorn and processes it using the following logic:

# Entry point as defined in https://github.com/frappe/frappe/blob/2295d3108db506ab24102123b7777a6c49d47e2b/frappe/app.py
@Request.application
def application(request: Request):
   try:
      init_request(request)
      validate_auth()
      if request.method == "OPTIONS":
         response = Response()
      elif request.path.startswith("/api/"):
         response = frappe.api.handle(request)
      elif request.path.startswith("/private/files/"):
         response = frappe.utils.response.download_private_file(request.path)
      elif request.method in ("GET", "HEAD", "POST"):
         response = get_response()
      else:
         raise NotFound
   except Exception as e:
      response = handle_exception(e)
   else:
      rollback = sync_database(rollback)
   finally:
      ...
   return response

A single Frappe instance (ie. a Bench) can host multiple sites. You may host a Postgres and another MariaDB site side by side on the same bench.

The init_request function is responsible for initializing the site configuration, creating a database connection and executing any before_request hooks by the installed applications. The frappe.local is a thread-safe object used to store the request context including the configuration, database connection & current user information.

The validate_auth function ensures that the request is authenticated and authorized to access the site. This sets the frappe.session object that contains the user's session information. If the user credentials aren't passed or are invalid, the "Guest" user is assumed.

Depending on the path of the request, the request is routed to the appropriate handler. The frappe.api.handle function handles API requests, the frappe.utils.response.download_private_file function handles file downloads, and the get_response function provides an extensible way to provide various renderers to handle responses. For further reading, check out the official documentation on routing & rendering. This acts as an entry point for executing endpoints and generating views part of your Frappe Apps or via rules & DocType configurations on your sites.

By default, Frappe maintains a single database transaction per request. The sync_database function is responsible for committing the transaction if no exceptions were raised, else it rolls back the transaction. This ensures that the database is in a consistent state after each request across Database types. This holds true for any "unsafe" HTTP requests (eg. POST, PUT, DELETE, PATCH, etc). For GET requests, the transaction is read-only and is not committed. This tends to be the source of a common gotcha.

Frappe tries to coerce the responses into a format that matches the Accept header of the request - whether your endpoint returns a native Python object or ends up raising an Exception.

A Response object is generated with the appropriate status code, headers, and body. The response is then returned to Gunicorn, which sends it back to the browser.


For our sample request GET /, we can assume the site has an index page that is rendered by the Frappe Web Server - using the Web Page feature. Based on the current (December 2024) implementation, the steps Frappe would take to build a Response would be as follows:

  1. Initialize the request context (init_request)

    • Load the site configuration (Read here how site configuration is resolved)
    • Create a database connection & start a transaction
  2. Validate the user's session (validate_auth)

    • Set the current user to "Guest"
  3. Route the request to the appropriate handler
  4. Build the response and return it

The response would be an HTML document that represents the home page of the site. The response may also include CSS, JS, images, and other assets that are needed to render the page.

Websocket Server

Frappe uses Socket.IO which utilizes web socket technology for real-time communication between the client and the server. This is a NodeJS server that runs alongside the Frappe Web Server. It is responsible for broadcasting real-time events like progress bars, list view updates, notifications, chat messages, and other real-time updates to the clients (web or mobile apps).

The Websocket server also works over the TCP connection, but it uses a different protocol than HTTP - the WebSocket protocol (WS). Each connection is authenticated by the server(s) to ensure multi-tenancy as well as Role & User Permissions are enforced.

Server Response

What has NGINX been doing all this while? NGINX has been waiting for a response from the Frappe Web Server. Once it receives the response, it forwards it back to the browser over the same TCP connection the request arrived from. The response is encrypted and sent back to the browser. Given our request was a GET / request, the response would be an HTML document that represents the home page of the site.

Browser Rendering

When the browser starts receiving the response, it may already start parsing and rendering the HTML document. The browser will parse the HTML, build the DOM, render the page, and execute any embedded CSS and JS. For a response that looks like the following, the browser will make additional requests to fetch the linked assets like CSS, JS, images, etc. and render the page accordingly.

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 1234
Date: Fri, 04 Nov 2024 12:00:00 GMT

<!DOCTYPE html>
<html>
<head>
   <title>Home Page</title>
   <link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
   <h1>Welcome to Cloud</h1>
   <img src="/assets/images/logo.png" alt="Cloud Logo">
   <script src="/assets/js/script.js"></script>
</body>
</html>

These subsequent requests for CSS, JS, images, or Websocket are made to the same server, but with different paths. While it is also possible to serve these assets from a different server or a CDN, the default setup serves them from the same server. These requests are handled by NGINX and forwarded to the appropriate backend server. For the case of /assets requests, NGINX serves the static assets from the ./sites/assets directory.

Lifecycle of a Job

A job in Frappe is a task that is executed asynchronously via the background workers. They may be enqueued by the scheduler defined through the Scheduled Job Type DocType, or by invoking the frappe.enqueue function in your code. Frappe's abstractions function similar for a job as they do for a web request - Apart from the flow differences, they are both jobs within which Frappe manages site locals, user sessions, and database transactions.

  • A job is enqueued – Via frappe.enqueue which stores the job details in Redis.
  • A worker picks up the job – The worker continuously monitors the queue and picks a job for execution.
  • The job is executed – The worker executes the function with the provided context. The job's status and additional metadata are updated in Redis.

The behaviour of the Background Worker may depend on your Bench configuration. However, considering the default strategy, Workers are long-running processes similar to Web Server workers. They monitor one or several Queues in Redis and pick up a job at the top of the queue, execute it, and mark it as completed. The worker will continue to pick up jobs until the queue is empty or the worker is stopped.

Let us consider an example where we wish to send a welcome email to a customer upon registration. We can focus on the relevant parts of the code that enqueues the email-sending task:

class Customer(Document):
    def after_insert(self):
        # Enqueue the email-sending task after the customer has inserted
         frappe.enqueue(
            build_and_send_welcome_email,
            queue="custom",
            enqueue_after_commit=True,
            full_name=self.full_name,
            sender=self.relationship_manager,
            customer=self.name,
         )

def build_and_send_welcome_email(full_name: str, sender: str, customer: str): ...

The function build_and_send_welcome_email is passed as the first argument to the frappe.enqueue function. The queue argument specifies the queue in which the job should be enqueued. The enqueue_after_commit argument specifies whether the job should be enqueued after the database transaction is committed. The full_name, sender and customer arguments are passed to the build_and_send_welcome_email function.

# Job details are stored in Redis as a hash
rq:job:cloud.gavv.in||182a7785-7b62-4a38-9770-14c693a31d6a
β”œβ”€β”€ created_at:   "2025-02-19T12:00:00"
β”œβ”€β”€ enqueued_at:  "2025-02-19T12:00:05"
β”œβ”€β”€ status:       "queued"
β”œβ”€β”€ result_ttl:   "3600"
β”œβ”€β”€ timeout:      "180"
β”œβ”€β”€ func:         "frappe.utils.background_jobs.execute_job"
β”œβ”€β”€ args:         "[]"
β”œβ”€β”€ kwargs: {
β”‚        "site": "cloud.gavv.in",
β”‚        "user": "Administrator",
β”‚        "method": "cloud.overrides.build_and_send_welcome_email",
β”‚        "event": null,
β”‚        "job_name": "build_and_send_welcome_email",
β”‚        "is_async": true,
β”‚        "kwargs": {
β”‚             "full_name": "John Doe",
β”‚             "sender": "rm@example.com",
β”‚             "customer": "CUST-0001"
β”‚        }
β”‚    }
β”œβ”€β”€ worker_name:  "worker-1"
β”œβ”€β”€ traceback:    ""
β”œβ”€β”€ result:       "\"Success\""
β”œβ”€β”€ ended_at:     "2025-02-19T12:02:00"

# job is added to a Redis List
rq:queue:custom
β”œβ”€β”€ cloud.gavv.in||182a7785-7b62-4a38-9770-14c693a31d6a
β”œβ”€β”€ job_id_2
β”œβ”€β”€ job_id_3

Background workers, similar to web workers, are site-agnostic and can pick up jobs from any site on the bench. During the execution of each job, a site context is initialised, the database connection is created & a transaction is started. Hooks are executed before and after the job execution, and the database transaction is committed upon successful job execution. Here is the simplified version of the execute_job function that is responsible for executing the job:

def execute_job(site: str, method, event, job_name, kwargs, user=None):
   retval = None

   # Initialize the Site environment and set the user
   frappe.init(site, force=True)
   frappe.connect()
   if user:
      frappe.set_user(user)

   # Prepare the job context
   frappe.local.job = frappe._dict(
      site=site,
      method=method,
      job_name=job_name,
      kwargs=kwargs,
      user=user,
      after_job=CallbackManager(),
   )

   # Execute before_job hooks
   for before_job_task in frappe.get_hooks("before_job"):
      frappe.call(before_job_task, method=method, kwargs=kwargs, transaction_type="job")

   try:
      # Execute the enqueued function
      retval = method(**kwargs)

   except Exception as e:
      # Log errored job execution
      frappe.db.rollback()
      frappe.log_error(title=method)
      frappe.db.commit()
      raise

   else:
      # Commit the database transaction upon successful execution
      frappe.db.commit()
      return retval

   finally:
      # Execute after_job hooks always - regardless of success or failure
      for after_job_task in frappe.get_hooks("after_job"):
         frappe.call(after_job_task, method=method, kwargs=kwargs, result=retval)
      frappe.local.job.after_job.run()

      # Clean up the site context
      frappe.destroy()

It's also noteworthy that, unlike other schedulers, Frappe's does not support arbitrary scheduling of jobs but rather relies on the Scheduled Job Type DocType to define the schedule.

From the Desk view, you can also monitor the status of the jobs and the workers via RQ Job and RQ Worker virtual DocTypes. There are a few commands that bundle with Frappe that allow you to manage the background workers and manage jobs for your sites [ref].

Lifecycle of a CLI Command

Frappe provides a command-line interface (CLI) which can be used via the Bench CLI tool. The Bench CLI is a command-line utility (built using the Click library) that allows you to interact with the Framework and site or bench apps from the terminal. It provides a set of commands that you can use to manage your dependencies, virtual environment, sites, apps & servers for production and development setups.

The lifecycle of a CLI command is similar to that of a web request or a job - however, with one main distinction. Frappe handles the setup of the site context, database connection, and transaction management for you in other contexts, but in the CLI, you as the author are responsible for setting and cleaning up the site context. It however does provide a few helper functions to make this easier. The same goes with making the command friendly for executions in a multi-tenant environment - --site all translates to context.sites having a resolved list of sites of the bench.

You may add a command like the following in your Frappe App which may be executed via the Bench CLI by running bench do-something-cool:

@click.command("do-something-cool")
@pass_context
def do_the_cool_thing(context: CliCtxObj):
   "Does the cool thing"
   # Ensure that a site is specified, else raise the conventional error
   if not context.sites:
      raise SiteNotSpecifiedError

   import frappe, toolbox

   # Passing --site to bench defines the site context
   for site in context.sites:
      try:
         # Initialize the site context and database connection
         frappe.init(site)
         frappe.connect()

         # Here goes your logic
         toolbox.do(verbose=context.verbose)

         # Commit the transaction
         frappe.db.commit()
      finally:
         # Cleanup the site context, close the database connection et al
         frappe.destroy()

Read more about extending the CLI in the official documentation.

Most commands are executed from within a bench directory. The Bench CLI tool is responsible for parsing the command, initializing the site context, and executing the command.

Conclusion

Frappe's execution modelβ€”whether for web requests, background jobs, or CLI commands, follows a consistent pattern: initialize site context, manage database transactions, execute logic, and clean up. This uniformity makes it easy to extend the framework while ensuring stability across different execution paths.

At its core, Frappe abstracts away a lot of the complexity, but properly handling transactions, ensuring multi-tenant compatibility, and managing resources effectivelyβ€”make all the difference. A deeper understanding of the underlying stack ensures you can design good systems.