| 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.10.0 |
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
blender --background --python my_script.pyOr with a specific .blend file:
blender --background scene.blend --python my_script.pyOr 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.
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).
The single most important pattern: prefer bpy.data.* over bpy.ops.* in batch scripts.
# WRONG: requires a 3D viewport context to even poll.
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.object.delete()
# RIGHT: works headless.
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)# WRONG: requires UI for the file dialog.
bpy.ops.export_scene.obj(filepath="/tmp/out.obj") # may also fail because the OBJ exporter is now under wm.obj_export
# RIGHT in 5.x: the new exporter operates on bpy.data, not the active object selection.
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.
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.
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.
"""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", "EEVEE"])
args = parser.parse_args(argv)
scene = bpy.context.scene
if args.engine == "EEVEE":
# EEVEE's engine id is 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5.
scene.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
else:
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 fewbpy.opscalls that work in--background.- EEVEE's engine identifier is
BLENDER_EEVEEon Blender 5.0+ andBLENDER_EEVEE_NEXTon 4.2-4.5 LTS (the id was reclaimed in 5.0 after legacy EEVEE was removed in 4.2). Detect viabpy.app.version.
import bpy
major, minor, _patch = bpy.app.version
if (major, minor) >= (5, 0):
eevee_engine = 'BLENDER_EEVEE'
else:
eevee_engine = 'BLENDER_EEVEE_NEXT'bpy.app.version is a (major, minor, patch) tuple, always reliable.
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)-
Calling UI-dependent
bpy.opsin headless without atemp_override:bpy.ops.object.select_all(action='DESELECT') # fails: no view3d
-
Using
bpy.context.scenebefore a scene is loaded. After Blender starts, the default startup file is loaded, socontext.sceneworks. But if you've calledbpy.ops.wm.read_factory_settings(use_empty=True), dereferencingcontext.scene.collectionmay surprise you. -
Forgetting the
--argv split. Without it,argparsesees Blender's own flags and rejects them. -
Writing into the current working directory assuming it's where the .blend lives. Cwd in
--backgroundis wherever the user ran Blender from. Use absolute paths orbpy.path.abspath("//foo")for relative-to-blend paths. -
Not handling Blender exit code. Wrap
main()andsys.exit(1)on failure. -
Trying to use modal operators in
--background. There is no event loop.
mesh-editing-and-bmeshforbpy.datapatterns to use in batch scriptsaddon-scaffoldingfor the registration patterns when an add-on must run headless- Rule
prefer-data-over-ops-in-loops - Snippet
temp-override-context.py
- Blender command-line arguments: https://docs.blender.org/manual/en/latest/advanced/command_line/arguments.html
bpy.context.temp_override: https://docs.blender.org/api/current/bpy.types.Context.htmlbpy.app.version: https://docs.blender.org/api/current/bpy.app.html