| name | headless-batch-scripting |
| description | Run Blender headless via blender --background --python script.py for batch jobs. What changes without a UI, how to avoid UI-dependent operators, the temp_override pattern when ops must be used, and argparse after the -- separator. |
| standards-version | 1.9.4 |
Headless Batch Scripting
Trigger
Use this skill when the user:
- Wants to render, export, or process .blend files from a CLI or CI pipeline
- Mentions
blender --background, --python, "headless", "batch render", "no UI"
- Has an operator script that fails with
RuntimeError: Operator bpy.ops.X.Y.poll() failed, context is incorrect
- Needs to pass arguments to a Blender Python script
The basic shape
blender --background --python my_script.py
Or with a specific .blend file:
blender --background scene.blend --python my_script.py
Or with arguments after --:
blender --background scene.blend --python my_script.py -- --output out.png --frames 1,5,10
--background (or -b) tells Blender not to launch a UI window. --python (or -P) runs a Python script. The script receives sys.argv containing everything after the -- separator.
What changes without a UI
The Blender API is the same in headless mode but several context-dependent behaviors break:
| Subsystem | Headless behavior |
|---|
bpy.context.window, bpy.context.area, bpy.context.region | Often None |
bpy.context.active_object | None if no scene is loaded or no active object set |
bpy.context.selected_objects | Empty by default |
Most bpy.ops.<editor>.* operators | Fail with "context is incorrect" because they expect an editor that does not exist |
| Modal operators | Cannot be used; no event loop |
| Drag and drop, file dialogs, keymaps | All gone |
Scene rendering (bpy.ops.render.render) | Works fine, this is the standard headless render path |
The headless API surface is essentially "everything that operates on bpy.data directly" plus a small whitelist of operators that do not depend on UI state (most render.*, most wm.* save/load, scene-level edits).
Rule of thumb: bpy.data is your friend
The single most important pattern: prefer bpy.data.* over bpy.ops.* in batch scripts.
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.object.delete()
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.ops.export_scene.obj(filepath="/tmp/out.obj")
bpy.ops.wm.obj_export(filepath="/tmp/out.obj", export_selected_objects=False)
The mesh-editing-and-bmesh skill covers the rest of the bpy.data and bmesh patterns. This skill is about when the headless context forces you toward those patterns.
When you must use a bpy.ops that wants UI: temp_override
A few bpy.ops calls have no bpy.data equivalent and genuinely need an editor context. For these, use bpy.context.temp_override:
import bpy
def find_window_and_area():
for window in bpy.data.window_managers[0].windows:
for area in window.screen.areas:
if area.type == 'VIEW_3D':
return window, area
return None, None
def run_in_view3d_context(callable_, *args, **kwargs):
window, area = find_window_and_area()
if window is None or area is None:
raise RuntimeError("No 3D viewport available")
region = next((r for r in area.regions if r.type == 'WINDOW'), None)
with bpy.context.temp_override(window=window, area=area, region=region):
return callable_(*args, **kwargs)
run_in_view3d_context(bpy.ops.object.transform_apply, location=True, rotation=True, scale=True)
In pure headless mode (--background), there are no windows at all, so this pattern only works if you have a "GUI but headless" setup (e.g. blender --python without --background, running on a virtual framebuffer).
For true headless, your only option is to find a bpy.data equivalent or pre-bake the operation into the .blend file.
Argument parsing after --
Blender consumes its own flags before --. Anything after -- is yours:
import argparse
import sys
argv = sys.argv
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
parser = argparse.ArgumentParser(description="Batch render N frames")
parser.add_argument("--output", required=True, help="Output filepath template, like /tmp/out_####.png")
parser.add_argument("--frames", default="1", help="Comma-separated frames or ranges, e.g. 1,5,10-20")
args = parser.parse_args(argv)
The if "--" in argv guard handles the case where the user did not pass any of their own arguments.
A complete worked example: headless batch render
"""Batch render specified frames to PNG. Usage:
blender --background scene.blend --python batch_render.py -- --output /tmp/r_####.png --frames 1,5,10
"""
import argparse
import os
import sys
import bpy
def parse_frames(spec):
frames = []
for chunk in spec.split(","):
if "-" in chunk:
lo, hi = chunk.split("-", 1)
frames.extend(range(int(lo), int(hi) + 1))
else:
frames.append(int(chunk))
return frames
def main():
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True)
parser.add_argument("--frames", default="1")
parser.add_argument("--engine", default="CYCLES", choices=["CYCLES", "BLENDER_EEVEE_NEXT"])
args = parser.parse_args(argv)
scene = bpy.context.scene
scene.render.engine = args.engine
scene.render.image_settings.file_format = 'PNG'
output_dir = os.path.dirname(args.output) or "."
os.makedirs(output_dir, exist_ok=True)
for frame in parse_frames(args.frames):
scene.frame_set(frame)
scene.render.filepath = args.output.replace("####", f"{frame:04d}")
bpy.ops.render.render(write_still=True)
print(f"Rendered frame {frame} -> {scene.render.filepath}")
if __name__ == "__main__":
main()
Notes:
bpy.ops.render.render(write_still=True) is one of the few bpy.ops calls that work in --background.
BLENDER_EEVEE_NEXT is the 5.x EEVEE engine identifier. On 4.5 LTS, use BLENDER_EEVEE. Detect via bpy.app.version.
Detecting Blender version in scripts
import bpy
major, minor, _patch = bpy.app.version
if (major, minor) >= (5, 0):
eevee_engine = 'BLENDER_EEVEE_NEXT'
else:
eevee_engine = 'BLENDER_EEVEE'
bpy.app.version is a (major, minor, patch) tuple, always reliable.
Exit codes
blender --background --python ... exits 0 on success and non-zero only if Blender itself errors. Your Python script's exceptions get logged but do not change the exit code by default.
For CI, explicitly call sys.exit:
try:
main()
except Exception as e:
print(f"FATAL: {e}", file=sys.stderr)
sys.exit(1)
Common AI mistakes
-
Calling UI-dependent bpy.ops in headless without a temp_override:
bpy.ops.object.select_all(action='DESELECT')
-
Using bpy.context.scene before a scene is loaded. After Blender starts, the default startup file is loaded, so context.scene works. But if you've called bpy.ops.wm.read_factory_settings(use_empty=True), dereferencing context.scene.collection may surprise you.
-
Forgetting the -- argv split. Without it, argparse sees Blender's own flags and rejects them.
-
Writing into the current working directory assuming it's where the .blend lives. Cwd in --background is wherever the user ran Blender from. Use absolute paths or bpy.path.abspath("//foo") for relative-to-blend paths.
-
Not handling Blender exit code. Wrap main() and sys.exit(1) on failure.
-
Trying to use modal operators in --background. There is no event loop.
Related
mesh-editing-and-bmesh for bpy.data patterns to use in batch scripts
addon-scaffolding for the registration patterns when an add-on must run headless
- Rule
prefer-data-over-ops-in-loops
- Snippet
temp-override-context.py
References