Firehot for hot reloading in Python

# June 24, 2025

header

I've spent a lot of time thinking about hot reloading in Python. Firehot is my answer to that problem for web development.

We can make a pretty simple observation about import dependencies: during development, your third-party dependencies (Django, Flask, requests, SQLAlchemy) almost never change. It's only your project code that's constantly being modified. So why restart everything when you could just restart the parts that actually change?

The approach in firehot works by process isolation. If you're careful with how you do it, a fork() in Unix will copy the exact runtime state into a fresh process. A Python interpreter is actually a pretty simple application executable to copy - it's single threaded and uses the GIL for locking C code. We can build up a Python process that preloads all of our third party sys.modules imported anywhere in our project. When our actual code hits that import it will be a no-op because it has already imported the bytecode.

The result is that when you change your project code, only your project modules get reloaded. The expensive third-party imports (Django, NumPy, etc) stay loaded and cached. Startup time can drop from 5+ seconds to 200ms.

Here's what a typical reload cycle looks like:

from firehot import isolate_imports
from os import getpid, getppid

def run_under_environment(value: int):
   parent_pid = getppid()
   print(f"Running with value: {value} (pid: {getpid()}, parent_pid: {parent_pid})")

# Scan through the current package and determine all imports
# Load these into a template environment that we can fork()
with isolate_imports("my_package") as environment:
   # Each exec will be run in a new process with the environment isolated, inheriting
   # the package's third party imports without having to re-import them from scratch.
   context1 = environment.exec(run_under_environment, 1)

   # These can potentially be long running - to wait on the completion status, you can do:
   result1 = environment.communicate_isolated(context1)

   # If you change the underlying my_project files on disk to add an import, you can run update_environment.
   environment.update_environment()

   # Subsequent execs will use the updated environment.
   context2 = environment.exec(run_under_environment, 2)
   result2 = environment.communicate_isolated(context2)

My mental model for this pipeline is like Dockerfiles: you build up each individual layer with package dependencies. If you need to reload any of them, you just need to unroll layers1 until you reach the one where they were declared and rebuild it.

The Import Tax

Python's import system has always been designed for production environments where you start once and run forever. But this creates an import tax that your dev workflow has to bear. You pay the fixed cost you pay every time your application boots. The tax gets higher as your dependency tree grows.

Modern Python applications routinely import hundreds of packages at startup. Each package can trigger its own initialization logic: database drivers establishing connection pools, ML libraries loading models into memory, web frameworks scanning for route definitions. What started as simple import requests statements compound into multi-second delays.

The conventional wisdom has been to live with it. "Just use gunicorn in production and accept the slow development cycle." But this feels backward. We've optimized for the 0.1% of time when code boots up in production at the expense of the 99.9% of bootup time when developers are iterating.

Firehot flips this equation. Instead of paying the import tax on every restart, you pay it once per session. The template process becomes your cached dependency state, and forking becomes your reload mechanism.

Why Fork Works

Python's import system is largely read-only after initialization. Once sys.modules is populated, imports become dictionary lookups. This is annoying if you're trying to manipulate the runtime imports but works pretty nicely in a forking context. The mutability of imports and isolation of forked processes let clients inherit the full import state without corrupting the parent.

If your updated code introduces a new third-party import, Firehot detects this via AST parsing and knows to rebuild the template process. If you just changed business logic, it skips the expensive rebuild and forks immediately.

This creates a performance profile that matches your development workflow: fast iterations for code changes, slower rebuilds only when you actually change dependencies. Most development sessions involve hundreds of code tweaks for every new package added.

The Rust Bridge

Writing this in pure Python would be possible but painfully slow. Python's subprocess management and AST parsing are decent, but not optimized for the tight loops that hot reloading demands. Every millisecond matters when you're trying to stay in flow state.

Firehot is mostly written in Rust, especially for the performance-critical paths: file watching, process management, and dependency analysis. Python handles the higher-level orchestration and API surface. It's the same pattern that's made tools like ruff and uv so much faster than their pure-Python predecessors. AST parsing is blazing fast in the Rust ecosystem.

The architecture also means Firehot can work with any Python web framework or application structure. It doesn't need to understand Django's auto-reloader or Flask's debug mode - it operates at the import level, which is universal across Python applications.

In practice, this feels like having your cake and eating it too. You get the ergonomics of rapid development cycles without sacrificing the rich ecosystem that makes Python productive in the first place. The expensive imports happen once, then fade into the background where they belong.


  1. Processes for Firehot, filesystem layers for docker. 

/dev/newsletter

Technical deep dives on machine learning research, engineering systems, and building scalable products. Published weekly.

Unsubscribe anytime. No spam, promise.