Overview
Modal provides lifecycle hooks that let you run code at specific points in a container’s lifetime. These hooks are essential for managing resources, initializing services, and performing cleanup operations.
Lifecycle phases
Modal containers go through several phases:
- Container creation: A new container is started
- Pre-snapshot enter (optional): Run once before container snapshot
- Container snapshot (optional): Container state is saved for fast cloning
- Post-snapshot enter: Run every time a container starts (or resumes from snapshot)
- Function execution: Your functions run
- Container exit: Run cleanup before container stops
Enter hook
The @modal.enter() decorator marks methods that run when a container starts.
Basic usage
import modal
app = modal.App()
@app.cls()
class MyService:
@modal.enter()
def initialize(self):
print("Container is starting!")
self.connection = connect_to_database()
@modal.method()
def process(self, data):
# Connection is already initialized
return self.connection.query(data)
Pre-snapshot vs post-snapshot
Use snap=True to run setup before the container is snapshot:
@app.cls(enable_memory_snapshot=True)
class MLModel:
@modal.enter(snap=True)
def load_model(self):
# Runs ONCE before snapshot
# Model is included in the snapshot for fast cold starts
import torch
self.model = torch.load("model.pt")
print("Model loaded into snapshot")
@modal.enter()
def setup_runtime(self):
# Runs EVERY time container starts (after snapshot restore)
import os
self.request_id = os.urandom(16).hex()
print(f"Container started with ID: {self.request_id}")
@modal.method()
def predict(self, data):
return self.model(data)
Use @modal.enter(snap=True) for expensive one-time setup like loading large models. Use @modal.enter() (without snap) for per-container initialization like creating unique IDs or establishing connections.
Multiple enter hooks
You can have multiple enter hooks in the same class:
@app.cls(enable_memory_snapshot=True)
class ComplexService:
@modal.enter(snap=True)
def load_assets(self):
# Load static assets before snapshot
self.model = load_model()
self.tokenizer = load_tokenizer()
@modal.enter(snap=True)
def compile_code(self):
# Compile code before snapshot
self.compiled = compile("optimized_code.py")
@modal.enter()
def setup_connections(self):
# Create connections after snapshot
self.db = connect_to_db()
self.cache = connect_to_cache()
Exit hook
The @modal.exit() decorator marks methods that run when a container stops.
Basic usage
@app.cls()
class DatabaseClient:
@modal.enter()
def connect(self):
self.conn = connect_to_database()
@modal.exit()
def disconnect(self):
print("Closing database connection")
self.conn.close()
@modal.method()
def query(self, sql: str):
return self.conn.execute(sql)
Cleanup operations
Use exit hooks for resource cleanup:
@app.cls()
class FileProcessor:
@modal.enter()
def setup(self):
import tempfile
self.temp_dir = tempfile.mkdtemp()
self.files_created = []
@modal.method()
def process(self, data):
filepath = f"{self.temp_dir}/data.txt"
self.files_created.append(filepath)
with open(filepath, "w") as f:
f.write(data)
@modal.exit()
def cleanup(self):
import os
import shutil
# Clean up temporary files
for filepath in self.files_created:
if os.path.exists(filepath):
os.remove(filepath)
# Remove temp directory
shutil.rmtree(self.temp_dir, ignore_errors=True)
print(f"Cleaned up {len(self.files_created)} files")
HTTP server example
A common pattern is running HTTP servers with lifecycle hooks:
import subprocess
import modal
app = modal.App()
image = (
modal.Image.debian_slim()
.apt_install("openssh-server")
.run_commands("mkdir /run/sshd")
)
@app._experimental_server(port=8000, proxy_regions=["us-east"])
class MyServer:
@modal.enter()
def start(self):
# Start HTTP server subprocess
self.proc = subprocess.Popen(["python3", "-m", "http.server", "8000"])
print("HTTP server started")
@modal.exit()
def stop(self):
# Gracefully stop the server
self.proc.terminate()
self.proc.wait(timeout=10)
print("HTTP server stopped")
The @app._experimental_server() decorator requires an @modal.enter() method to start the server. See the server documentation for more details.
Function-level lifecycle
Lifecycle hooks work with @app.function() too:
app = modal.App()
@app.function()
class Worker:
@modal.enter()
def setup(self):
print("Worker starting")
self.state = {"processed": 0}
@modal.exit()
def teardown(self):
print(f"Worker stopping. Processed {self.state['processed']} items")
def __call__(self, item):
self.state["processed"] += 1
return process(item)
Advanced patterns
Lazy initialization
Initialize resources only when needed:
@app.cls()
class LazyService:
def __init__(self):
self._db_connection = None
@property
def db(self):
if self._db_connection is None:
self._db_connection = connect_to_database()
return self._db_connection
@modal.exit()
def cleanup(self):
if self._db_connection is not None:
self._db_connection.close()
@modal.method()
def query(self, sql: str):
# DB connection created on first use
return self.db.execute(sql)
Shared state across methods
Use enter hooks to initialize shared state:
@app.cls()
class StatefulService:
@modal.enter()
def initialize_state(self):
self.cache = {}
self.request_count = 0
@modal.method()
def get_or_compute(self, key: str):
self.request_count += 1
if key not in self.cache:
self.cache[key] = expensive_computation(key)
return self.cache[key]
@modal.method()
def stats(self):
return {
"requests": self.request_count,
"cache_size": len(self.cache)
}
Resource pooling
Create connection pools in enter hooks:
@app.cls()
class PooledClient:
@modal.enter()
def create_pool(self):
from multiprocessing import Pool
self.pool = Pool(processes=4)
@modal.exit()
def close_pool(self):
self.pool.close()
self.pool.join()
@modal.method()
def parallel_process(self, items: list):
return self.pool.map(process_item, items)
Best practices
Always pair enter and exit
If you acquire a resource in @modal.enter(), release it in @modal.exit():
@modal.enter()
def acquire_resource(self):
self.resource = acquire()
@modal.exit()
def release_resource(self):
self.resource.release()
Use snap for expensive setup
Load large models and assets with snap=True:
@modal.enter(snap=True)
def load_model(self):
# This runs once and is included in the snapshot
self.model = load_huge_model()
Exit hooks should handle cleanup even if errors occurred:
@modal.exit()
def cleanup(self):
try:
if hasattr(self, 'connection'):
self.connection.close()
except Exception as e:
print(f"Cleanup error: {e}")
Minimize work in post-snapshot enter hooks to reduce cold start time:
# ✓ Good - expensive work in snapshot
@modal.enter(snap=True)
def load_model(self):
self.model = load_huge_model()
@modal.enter()
def setup_runtime(self):
self.request_id = generate_id() # Fast
# ✗ Bad - expensive work after snapshot
@modal.enter()
def setup(self):
self.model = load_huge_model() # Slow cold starts!
Requirements
- Enter and exit methods must take no parameters (except
self for class methods)
- Lifecycle decorators cannot be combined with web interface decorators (
@modal.fastapi_endpoint, @modal.asgi_app, etc.)
- Lifecycle decorators cannot be combined with callable interface decorators when used incorrectly
Common errors
Async functions not supported
# ✗ Incorrect - async not allowed
@modal.enter()
async def setup(self):
await initialize()
# ✓ Correct - use sync functions
@modal.enter()
def setup(self):
initialize()
Parameters not allowed
# ✗ Incorrect - parameters not allowed
@modal.enter()
def setup(self, config):
self.config = config
# ✓ Correct - no parameters
@modal.enter()
def setup(self):
self.config = load_config()
Cannot apply to classes
# ✗ Incorrect - can't apply to class
@modal.enter()
@app.cls()
class MyClass:
pass
# ✓ Correct - apply to methods
@app.cls()
class MyClass:
@modal.enter()
def setup(self):
pass