Cartoon developer at a desk with a Blender subprocess render visualization on screen

Blender Add-on Scene Data and Subprocess Rendering with Python

Post 6 of 10 — Blender Add-on Development with Python


In this series: We’re building a Batch Render Manager add-on from scratch. Post 1 covered the basics. Post 2 set up the package structure. Post 3 built the five core operators. Post 4 built the panel UI. Post 5 built the data model. This post runs the first real renders.


Five posts in, and we have a queue that looks right, feels right, and persists correctly. You can add jobs, configure overrides, and save your file — everything’s there. But clicking Start Queue still just logs “Queue started” to the Info bar.

That changes today. We’re going to build queue_runner.py — the piece that actually executes renders. By the end of this post:

  • Each job in the queue will render its .blend file as a background subprocess
  • Per-job override settings (resolution, samples, output path) will be injected into the render without touching the source file
  • The queue will run jobs sequentially, updating status on each one

This is also the post where we properly meet bpy.data — Blender’s scene data API — and understand why rendering other .blend files requires a completely different approach than you might expect.


Why You Can’t Just Load a .blend and Render It

When you first think about rendering a queue of .blend files, the obvious approach might be: load each file, change some settings, render, repeat. In Blender Python, that might look like using bpy.ops.wm.open_mainfile() or bpy.data.libraries.load() to bring in each file.

The problem is that Blender is a single-document application. There’s one active file open at a time. If you call bpy.ops.wm.open_mainfile() to load a different .blend file, you’ve just replaced your current session — including the running add-on, the queue data attached to the scene, and everything else. Your queue disappears mid-run.

bpy.data.libraries.load() is a read-only link mechanism for pulling data blocks (objects, materials, node groups) from another file into the current scene. It’s not designed for rendering full scenes from external files.

The right approach — the one Blender itself uses for its own render farm integrations — is to run Blender as a subprocess. You launch a second, completely separate Blender process with no UI, point it at the .blend file you want to render, and let it run. Your main Blender session stays intact, queue and all.

This is what blender --background is for.


bpy.data: What It Is and When You Use It

Before we get into subprocesses, let’s talk about bpy.data — because we will use it in this post, just not the way you might expect.

bpy.data is your access point to everything stored in the current Blender file: meshes, objects, materials, scenes, cameras, render settings, and more. It’s the in-memory representation of the .blend file currently open.

# The active scene's render settings
bpy.data.scenes["Scene"].render.resolution_x

# All meshes in the current file
for mesh in bpy.data.meshes:
    print(mesh.name)

# A specific object
obj = bpy.data.objects["Cube"]
Python

bpy.data and bpy.context are related but distinct. bpy.context gives you the current state — what’s selected, what mode you’re in, what the active object is. bpy.data gives you the raw data — every datablock in the file, whether it’s active or not.

For the queue runner, bpy.data matters in two ways:

  1. We can inspect the current scene’s render settings to use as sensible fallback defaults when a job has no overrides configured.
  2. The Python snippet we inject into each subprocess will use bpy.data to apply override settings to that subprocess’s scene before rendering.

Blender’s Command-Line Rendering

Blender has had command-line rendering since long before the Python API existed. The key flags:

blender --background path/to/file.blend --render-output /tmp/render/ --render-anim
  • --background (or -b) — run Blender without a UI. Essential for background processing.
  • path/to/file.blend — the file to open.
  • --render-output (or -o) — override the output path.
  • --render-anim (or -a) — render the animation (all frames in the frame range).
  • --render-frame (or -f) — render a single specific frame (e.g., -f 1). Does not accept a range.
  • -s <frame> / -e <frame> — set the start and end frame for --render-anim (e.g., -s 1 -e 250). Our queue runner injects frame range via --python-expr instead, but these flags exist and work if you’re scripting Blender directly from the command line.
  • --python-expr (or -E) — run a Python expression before rendering.

That last one is the key to per-job overrides. --python-expr lets you pass a Python string that runs inside the subprocess’s Blender session before the render starts. Because it runs in the context of the loaded .blend file, it has full access to bpy.data, bpy.context, and the scene’s render settings.

A minimal example:

blender --background scene.blend --python-expr "import bpy; bpy.data.scenes[0].render.resolution_x = 1280" --render-anim

That resizes the render resolution to 1280px wide for just this subprocess — the source .blend file is never modified. The override is applied in memory for the duration of that render and then disappears.

This is a clean, reliable pattern. It’s the same mechanism used by render farm tools, CI pipelines, and automation scripts that work with Blender.


Finding the Blender Executable

To launch a subprocess, we need to know where the Blender executable is. We can’t hardcode it — it’s different on every system and every installation.

The right way to get it is bpy.app.binary_path:

import bpy
print(bpy.app.binary_path)
# /Applications/Blender.app/Contents/MacOS/Blender  (macOS)
# C:\Program Files\Blender Foundation\Blender 4.2\blender.exe  (Windows)
# /usr/bin/blender  (Linux)
Python

This is always the executable that’s currently running. When you launch a subprocess with this path, you’re guaranteed to use the same Blender version — which matters, because .blend files from one version can behave differently in another.


Building queue_runner.py

Let’s build the runner. The design is straightforward: iterate over the queue, skip anything that isn’t pending, launch a subprocess for each job, and update status based on the result.

# queue_runner.py
# Handles background rendering of queued .blend files.

import bpy
import subprocess
import os

def build_python_expr(job):
    """
    Build the --python-expr string to inject override settings
    into the subprocess Blender session before rendering.
    """
    lines = ["import bpy", "scene = bpy.data.scenes[0]"]
    lines.append(f"scene.frame_start = {job.frame_start}")
    lines.append(f"scene.frame_end = {job.frame_end}")
    has_overrides = True

    if job.override_resolution:
        lines.append(f"scene.render.resolution_x = {job.res_x}")
        lines.append(f"scene.render.resolution_y = {job.res_y}")
        has_overrides = True

    if job.override_samples:
        # Cycles and EEVEE store samples differently.
        # We set both so the override works regardless of render engine.
        lines.append(f"scene.cycles.samples = {job.samples}")
        lines.append(f"scene.eevee.taa_render_samples = {job.samples}")
        has_overrides = True

    if job.override_output:
        # Normalize the path so it works cross-platform.
        path = os.path.normpath(job.output_path)
        # Escape backslashes for the Python string we're building.
        escaped = path.replace("\\", "\\\\")
        lines.append(f"scene.render.filepath = '{escaped}'")
        has_overrides = True

    if not has_overrides:
        return None

    return "; ".join(lines)

def build_command(job):
    """
    Build the full subprocess command list for a single render job.
    """
    blender_path = bpy.app.binary_path
    cmd = [
        blender_path,
        "--background",
        job.filepath,
    ]

    python_expr = build_python_expr(job)
    if python_expr:
        cmd.extend(["--python-expr", python_expr])

    # Use per-job frame range.
    cmd.extend(["--render-anim"])

    return cmd

def run_queue(context):
    """
    Run all pending, enabled jobs in the queue sequentially.

    Updates job status as each job starts and finishes.
    This is a blocking call — in Post 7 we'll move it into a
    modal operator so the UI stays responsive during long renders.
    """
    jobs = context.scene.batch_render_jobs

    for job in jobs:
        if not job.enabled or job.status != 'PENDING':
            continue

        # Validate the file path before launching.
        if not os.path.isfile(job.filepath):
            job.status = 'FAILED'
            print(f"[Batch Render] ERROR: File not found: {job.filepath}")
            continue

        job.status = 'RUNNING'

        cmd = build_command(job)
        print(f"[Batch Render] Starting: {job.name}")
        print(f"[Batch Render] Command: {' '.join(cmd)}")

        try:
            result = subprocess.run(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
            )

            if result.returncode == 0:
                job.status = 'DONE'
                print(f"[Batch Render] Done: {job.name}")
            else:
                job.status = 'FAILED'
                print(f"[Batch Render] FAILED: {job.name}")
                print(result.stderr)

        except Exception as e:
            job.status = 'FAILED'
            print(f"[Batch Render] Exception running {job.name}: {e}")

    print("[Batch Render] Queue complete.")
Python

Let’s walk through the key pieces.


build_python_expr()

This function is where override injection lives. It builds a Python expression string — a series of assignments separated by semicolons — that will run inside the subprocess before the render starts.

Frame range (scene.frame_start / scene.frame_end) is always injected, unconditionally. --frame-start and --frame-end aren’t valid CLI flags for --render-anim — the render uses whatever is set on the scene. Setting it via --python-expr is the correct approach, and it keeps frame range consistent with all the other overrides.

A few other things worth noting:

bpy.data.scenes[0] — In a freshly opened .blend file with --background, there’s almost always exactly one scene. Using scenes[0] is more reliable than scenes["Scene"] because the scene name could be anything. If you needed to target a specific scene by name, you’d do that differently — but for a general-purpose queue runner, index 0 is the right default.

Samples for Cycles vs. EEVEE — Blender’s two primary render engines store sample counts in different places. scene.cycles.samples is for Cycles. scene.eevee.taa_render_samples is for EEVEE Next (Blender 4.x). We set both because we don’t know which engine the source file uses, and setting an attribute on the wrong engine has no effect. This is pragmatic rather than elegant, but it works reliably.

Path escaping — Windows paths contain backslashes, which are escape characters in Python strings. If we blindly embed C:\renders\output into a Python string, we get C:\renders\output where \r is a carriage return. We need C:\\renders\\output. The path.replace("\\", "\\\\") handles this.

has_overrides guard — Because frame range is always injected, has_overrides is always True and build_python_expr() always returns a non-None string. The guard remains in place for the optional overrides in case the logic changes later, but in practice --python-expr is always included in the command.


build_command()

This assembles the full command as a Python list — the format subprocess.run() expects. Each element is a separate string, which means paths with spaces are handled correctly. Never build shell commands by concatenating strings with spaces — subprocess lists are the right way.

Frame range is no longer passed as CLI flags — --frame-start and --frame-end aren’t actually valid Blender command-line arguments for --render-anim. Instead, frame_start and frame_end are always injected via build_python_expr(), setting them directly on the scene object before the render starts. --render-anim then uses those scene values. This is the same mechanism as the other overrides — in-memory, subprocess-only, source file untouched.


subprocess.run()

subprocess.run() launches the external process and waits for it to finish. The key arguments:

  • stdout=subprocess.PIPE and stderr=subprocess.PIPE — capture the output instead of letting it spill into your terminal. We print stderr on failure so you can see what Blender reported.
  • text=True — decode the output as a string instead of bytes.
  • returncode — Blender exits with 0 on success, non-zero on failure. This is the clean signal for whether the render succeeded.

One important note: subprocess.run() is blocking. Your Blender session freezes while the subprocess runs. For short test renders that’s acceptable, but for a real queue of heavy renders, you’d be sitting there with a locked UI for hours.

We’ll fix this in Post 7 with a modal operator. The queue runner will be called from a timer-driven modal loop instead of directly, which keeps the UI responsive. For now, the blocking behavior is fine — the render logic is correct, and correctness comes before responsiveness.


Wiring Up start_queue

Now that queue_runner.py exists, we can connect the Start Queue operator to it. Open operators.py and update BATCHRENDER_OT_start_queue:

from . import queue_runner

class BATCHRENDER_OT_start_queue(bpy.types.Operator):
    """Start processing the render queue"""
    bl_idname = "batch_render.start_queue"
    bl_label = "Start Queue"
    bl_options = {'REGISTER'}

    @classmethod
    def poll(cls, context):
        jobs = context.scene.batch_render_jobs
        return any(j.status == 'PENDING' and j.enabled for j in jobs)

    def execute(self, context):
        queue_runner.run_queue(context)
        return {'FINISHED'}
Python

The import goes at the top of operators.py as a relative import: from . import queue_runner. This is the same pattern as the imports in __init__.py.

queue_runner.py doesn’t define any Blender classes, so it doesn’t need a register() or unregister() — and it doesn’t need to be in __init__.py‘s import list for registration purposes. It just needs to be importable, which it is as part of the package.


Resetting the Queue

There’s one thing you’ll notice the moment you run a test: after the queue finishes, the Start Queue button greys out and won’t re-enable. This is because poll returns False once all jobs are DONE or FAILED — there are no PENDING jobs left to run.

The fix is a Reset Queue operator that sets completed jobs back to PENDING. Add it to operators.py:

class BATCHRENDER_OT_reset_queue(bpy.types.Operator):
    """Reset all completed and failed jobs back to pending"""
    bl_idname = "batch_render.reset_queue"
    bl_label = "Reset Queue"
    bl_options = {'REGISTER', 'UNDO'}

    @classmethod
    def poll(cls, context):
        jobs = context.scene.batch_render_jobs
        return any(j.status in {'DONE', 'FAILED'} for j in jobs)

    def execute(self, context):
        for job in context.scene.batch_render_jobs:
            if job.status in {'DONE', 'FAILED'}:
                job.status = 'PENDING'
        return {'FINISHED'}
Python

The poll is the mirror image of start_queue‘s poll — the Reset button only enables when there’s something to reset, and the Start button only enables when there’s something to run. They naturally alternate.

Don’t forget to register it. Add BATCHRENDER_OT_reset_queue to the classes list alongside the other operators.

Then wire up a button in panels.py. Find where Start Queue and End Queue are drawn and add Reset Queue below them:

row = layout.row(align=True)
row.operator("batch_render.reset_queue")
Python

Using align=True keeps them visually grouped. After a run completes, clicking Reset sets every DONE and FAILED job back to PENDING, which immediately re-enables Start Queue.


A Closer Look at bpy.data in the Subprocess

It’s worth being precise about what’s happening when our --python-expr string runs.

When Blender launches with --background file.blend, it:

  1. Starts a Python interpreter
  2. Loads the .blend file into memory — all of its scenes, objects, render settings
  3. Runs any --python-expr strings in order
  4. Runs the render operation
  5. Exits

During step 3, bpy.data reflects the contents of the loaded .blend file. Our expression modifies bpy.data.scenes[0].render.* in-memory. The render in step 4 uses those modified values. The source file on disk is never written to. When the process exits in step 5, the modified state disappears.

This is a completely clean override mechanism. The source .blend files are never modified, which means:

  • Artists can safely have their files open in another Blender session while the queue is running
  • You can re-run the same queue with different overrides without any cleanup
  • Failed renders leave no artifacts behind in the source files

This isolation is one of the main reasons the subprocess approach is the right tool for this job.


Pre-flight Validation

The file-existence check in run_queue() is the beginning of error handling, but it’s worth understanding what it does and doesn’t cover.

Currently we check os.path.isfile(job.filepath) before launching. That catches the most common failure: a path that doesn’t point to an actual file. A missing file would otherwise cause Blender to launch, immediately fail with an error, and exit with a non-zero return code — which we’d catch correctly, but the error message from Blender would be less clear than our own.

There are other things we’re not checking yet:

  • Is the path actually a .blend file?
  • Is the file readable (permissions)?
  • Does the file’s frame_end >= frame_start?

We’ll add comprehensive pre-flight validation in Post 9 when we cover error handling and recovery. For now, the file-existence check is enough to handle the most obvious failure case without cluttering the runner.


Testing It

To test this end-to-end, you’ll need a real .blend file with at least one scene. Any file will do — even a simple default cube.

  1. Reload the add-on.
  2. In the N-panel, click + to add a job.
  3. Open the Jobs Overrides sub-panel and set the File Path to a real .blend file on your system.
  4. Set a frame range — start at 1, end at 1 to do a single frame and keep the test fast.
  5. Optionally enable a resolution override.
  6. Click Start Queue.

Blender will appear to freeze while the subprocess runs. For a single frame this is usually just a few seconds. You should see log output in your terminal (or in the Blender system console on Windows: Window > Toggle System Console):

[Batch Render] Starting: Job 1
[Batch Render] Command: /path/to/blender --background /path/to/file.blend --python-expr "import bpy; scene = bpy.data.scenes[0]; scene.frame_start = 1; scene.frame_end = 1" --render-anim
[Batch Render] Done: Job 1
[Batch Render] Queue complete.

After it finishes, the job’s status in the panel should update to Done. If anything goes wrong, it should show Failed, and the stderr output will be in the console.

To test the override injection specifically, add a job with Resolution Override enabled, set it to something obviously different from your scene’s default (say, 640×480), and check the rendered output. The output should be at 640×480 even though you never touched the source file.


The Complete queue_runner.py

# queue_runner.py
# Handles background rendering of queued .blend files.

import bpy
import subprocess
import os

def build_python_expr(job):
    """
    Build the --python-expr string to inject override settings
    into the subprocess Blender session before rendering.
    """
    lines = ["import bpy", "scene = bpy.data.scenes[0]"]
    lines.append(f"scene.frame_start = {job.frame_start}")
    lines.append(f"scene.frame_end = {job.frame_end}")
    has_overrides = True

    if job.override_resolution:
        lines.append(f"scene.render.resolution_x = {job.res_x}")
        lines.append(f"scene.render.resolution_y = {job.res_y}")
        has_overrides = True

    if job.override_samples:
        lines.append(f"scene.cycles.samples = {job.samples}")
        lines.append(f"scene.eevee.taa_render_samples = {job.samples}")
        has_overrides = True

    if job.override_output:
        path = os.path.normpath(job.output_path)
        escaped = path.replace("\\", "\\\\")
        lines.append(f"scene.render.filepath = '{escaped}'")
        has_overrides = True

    if not has_overrides:
        return None

    return "; ".join(lines)

def build_command(job):
    """
    Build the full subprocess command list for a single render job.
    """
    blender_path = bpy.app.binary_path
    cmd = [
        blender_path,
        "--background",
        job.filepath,
    ]

    python_expr = build_python_expr(job)
    if python_expr:
        cmd.extend(["--python-expr", python_expr])

    cmd.extend(["--render-anim"])

    return cmd

def run_queue(context):
    """
    Run all pending, enabled jobs in the queue sequentially.

    Updates job status as each job starts and finishes.
    Blocking call — the UI will freeze during renders.
    Post 7 moves this into a modal operator for a responsive UI.
    """
    jobs = context.scene.batch_render_jobs

    for job in jobs:
        if not job.enabled or job.status != 'PENDING':
            continue

        if not os.path.isfile(job.filepath):
            job.status = 'FAILED'
            print(f"[Batch Render] ERROR: File not found: {job.filepath}")
            continue

        job.status = 'RUNNING'

        cmd = build_command(job)
        print(f"[Batch Render] Starting: {job.name}")
        print(f"[Batch Render] Command: {' '.join(cmd)}")

        try:
            result = subprocess.run(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
            )

            if result.returncode == 0:
                job.status = 'DONE'
                print(f"[Batch Render] Done: {job.name}")
            else:
                job.status = 'FAILED'
                print(f"[Batch Render] FAILED: {job.name}")
                print(result.stderr)

        except Exception as e:
            job.status = 'FAILED'
            print(f"[Batch Render] Exception running {job.name}: {e}")

    print("[Batch Render] Queue complete.")
Python

What We Have Now

Let’s take stock of where the add-on stands after six posts:

  • A real queue that renders actual .blend files via subprocess
  • Per-job overrides (resolution, samples, output path, frame range) injected cleanly via --python-expr
  • Status tracking through PENDING → RUNNING → DONE/FAILED
  • Source files are never modified — overrides are in-memory only
  • Feature 1 (multi-.blend queue) and Feature 2 (per-item overrides) are both functionally complete

The one significant limitation is the blocking UI. A single-frame test render barely notices it, but queue a dozen scenes at full quality and your Blender session will be locked for hours. That’s the problem Post 7 solves.


What’s Coming Next

In Post 7, we’ll build a modal operator that drives the queue without blocking the UI. Blender’s bpy.app.timers system lets us run Python callbacks on a schedule — we’ll use it to poll subprocess state between UI redraws, so the progress bar updates in real time and you can still interact with Blender while renders are running. We’ll also wire up the Stop Queue operator to actually cancel an in-progress render.

That’s when the Batch Render Manager starts to feel like a real tool.

See you there.


Have questions or ran into a snag? Drop a comment below. And if you want work-in-progress updates, upcoming tutorials, and early looks at what I’m building, subscribe to the newsletter.

Visited 5 times, 5 visit(s) today

Leave a Reply

Your email address will not be published. Required fields are marked *