Post 5 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. This post wires it all together by defining the data model.
We’ve been building toward this post for a while. We have operators that can run, a panel that looks right, and a UIList class ready to render rows — but there’s nothing to render. The template_list() call in panels.py is still commented out. The poll() methods in our operators all return True unconditionally. The queue exists in name only.
That changes today. By the end of this post, the queue will be real. You’ll be able to add jobs, see them listed in the panel, select one, and see its override settings. The data will persist with your Blender file. And the operators and panel will finally talk to each other through shared state.
The tool that makes all of this possible is bpy.props — Blender’s property system.
A Note on bpy.props
If you’ve used Blender’s Python properties in other contexts, some of this post will be familiar ground. I’ve actually written a more general introduction to bpy.props on this site — you can find it here — that covers the full range of property types and how they work in Python. This post builds on that foundation but stays focused on what we specifically need for the Batch Render Manager: a PropertyGroup that defines our queue item data model, registration on bpy.types.Scene, and the wiring that connects properties to the panel UI.
If you hit something here that feels under explained, the other post is a good companion reference.
What Is a PropertyGroup?
In Blender’s data model, properties can’t just float around as plain Python variables. They need to be defined in a way Blender understands — one that supports serialization (saving to .blend), undo, the UI system, and RNA (Blender’s internal data API).
A PropertyGroup is a class that bundles a set of related properties together. Think of it as a struct or a dataclass, but defined in a way Blender knows how to store, serialize, and expose through its UI.
Here’s the minimal shape:
import bpy
class MyItem(bpy.types.PropertyGroup):
name: bpy.props.StringProperty(name="Name", default="")
value: bpy.props.IntProperty(name="Value", default=0)PythonYou use Python’s type annotation syntax (:) to declare properties, paired with a bpy.props call that defines the type and options. This isn’t just a style choice — Blender specifically looks for this pattern during registration to wire up the RNA system.
Once registered, instances of MyItem can be stored in collections, attached to Blender data types like Scene, and edited through the UI.
The Properties We Need
For the Batch Render Manager, we need two PropertyGroups:
RenderJobItem— one instance per queue entry. Holds the job’s file path, label, current status, and per-job override settings.We’ll attach a
CollectionPropertyofRenderJobIteminstances — plus an active index — tobpy.types.Scene. That’s what thetemplate_list()call will point at.
Let’s think through the fields on RenderJobItem:
| Field | Type | Purpose |
|---|---|---|
name | StringProperty | Human-readable label for the job |
filepath | StringProperty | Path to the .blend file to render |
status | EnumProperty | One of: PENDING, RUNNING, DONE, FAILED |
enabled | BoolProperty | Whether this job is included in the queue run |
override_resolution | BoolProperty | Toggle: use the override resolution for this job |
res_x | IntProperty | Override resolution X |
res_y | IntProperty | Override resolution Y |
override_samples | BoolProperty | Toggle: use the override sample count |
samples | IntProperty | Override sample count |
override_output | BoolProperty | Toggle: use the override output path |
output_path | StringProperty | Override output path |
frame_start | IntProperty | Start frame for this job |
frame_end | IntProperty | End frame for this job |
The pattern for overrides is deliberate: each override field is paired with a boolean toggle. This lets users opt in to overriding specific settings without having to set a value for every field on every job. We’ll use these toggles in Post 6 when we inject overrides into the subprocess render.
Building properties.py
Open properties.py and let’s fill it in.
import bpy
# Status values used by the queue runner and the UI.
JOB_STATUS_ITEMS = [
('PENDING', "Pending", "Waiting to be rendered"),
('RUNNING', "Running", "Currently rendering"),
('DONE', "Done", "Rendered successfully"),
('FAILED', "Failed", "Render failed or was cancelled"),
]
class RenderJobItem(bpy.types.PropertyGroup):
"""Represents a single render job in the queue."""
# --- Identity ---
name: bpy.props.StringProperty(
name="Job Name",
description="A label for this render job",
default="Untitled Job",
)
filepath: bpy.props.StringProperty(
name="File Path",
description="Path to the .blend file to render",
default="",
subtype='FILE_PATH',
)
enabled: bpy.props.BoolProperty(
name="Enabled",
description="Include this job in the queue run",
default=True,
)
status: bpy.props.EnumProperty(
name="Status",
description="Current state of this render job",
items=JOB_STATUS_ITEMS,
default='PENDING',
)
# --- Resolution Override ---
override_resolution: bpy.props.BoolProperty(
name="Override Resolution",
description="Use a custom resolution for this job instead of the scene default",
default=False,
)
res_x: bpy.props.IntProperty(
name="Resolution X",
description="Override render width in pixels",
default=1920,
min=4,
soft_max=7680,
)
res_y: bpy.props.IntProperty(
name="Resolution Y",
description="Override render height in pixels",
default=1080,
min=4,
soft_max=4320,
)
# --- Samples Override ---
override_samples: bpy.props.BoolProperty(
name="Override Samples",
description="Use a custom sample count for this job",
default=False,
)
samples: bpy.props.IntProperty(
name="Samples",
description="Override render sample count",
default=128,
min=1,
soft_max=4096,
)
# --- Output Path Override ---
override_output: bpy.props.BoolProperty(
name="Override Output Path",
description="Use a custom output path for this job",
default=False,
)
output_path: bpy.props.StringProperty(
name="Output Path",
description="Override output directory or file path",
default="",
subtype='DIR_PATH',
)
# --- Frame Range ---
frame_start: bpy.props.IntProperty(
name="Frame Start",
description="First frame to render for this job",
default=1,
min=0,
)
frame_end: bpy.props.IntProperty(
name="Frame End",
description="Last frame to render for this job",
default=250,
min=0,
)
classes = [
RenderJobItem,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
# Attach the queue collection and active index to Scene.
bpy.types.Scene.batch_render_jobs = bpy.props.CollectionProperty(
type=RenderJobItem,
name="Render Queue",
description="List of render jobs in the batch queue",
)
bpy.types.Scene.batch_render_active_job_index = bpy.props.IntProperty(
name="Active Job Index",
description="Index of the currently selected render job",
default=0,
)
def unregister():
del bpy.types.Scene.batch_render_jobs
del bpy.types.Scene.batch_render_active_job_index
for cls in reversed(classes):
bpy.utils.unregister_class(cls)PythonA few things worth calling out here before we move on.
subtype — Property Subtypes
You’ll notice subtype='FILE_PATH' on filepath and subtype='DIR_PATH' on output_path. Subtypes tell Blender to treat these string properties specially in the UI — instead of a plain text box, you get a path field with a folder-browse button. The stored value is still just a string; the subtype only affects how it’s displayed and edited.
Useful subtypes for StringProperty include 'FILE_PATH', 'DIR_PATH', and 'NONE' (the default). For numeric properties, subtypes like 'PIXEL', 'PERCENTAGE', 'ANGLE', and 'DISTANCE' add units and appropriate display formatting.
min, soft_max, and max
For IntProperty and FloatProperty, you can set hard and soft limits:
min/max— hard limits. The user cannot go below or above these values.soft_min/soft_max— soft limits. The drag slider is bounded by these, but the user can type a value outside them.
For res_x, we use min=4 (you can’t render a 0-pixel-wide image) and soft_max=7680 (8K wide — a sensible upper end for the slider without preventing higher values if someone needs them). This is a user experience detail, but it’s the kind of thing that makes a polished add-on feel considered.
EnumProperty and items
The status field uses an EnumProperty. The items argument takes a list of tuples, each with three elements: (identifier, label, description).
identifieris the internal value stored in Python — what you’ll compare against in code:if job.status == 'FAILED'labelis what the user sees in the UIdescriptionis the tooltip
We’re defining JOB_STATUS_ITEMS as a module-level constant rather than inline. This matters: we’ll need to reference these values by identifier in the operators (to check status and set it) and in the poll() methods. Having them defined once at the top of the module makes that straightforward.
Attaching to bpy.types.Scene
The CollectionProperty and IntProperty for the active index aren’t defined inside RenderJobItem — they’re attached to bpy.types.Scene in register(). This is the standard way to store add-on data that belongs to a scene.
bpy.types.Scene.batch_render_jobs = bpy.props.CollectionProperty(
type=RenderJobItem,
...
)PythonThis dynamically adds a property to every Scene object in Blender. From that point on, you can access the queue from anywhere in the add-on as context.scene.batch_render_jobs.
The corresponding cleanup in unregister() uses del to remove these properties. If you forget this, Blender may log warnings about unknown properties when saving or loading files after disabling the add-on.
One important detail: RenderJobItem must be registered before we can reference it in the CollectionProperty(type=RenderJobItem) call. That’s why in register(), we register the class first, then attach the scene properties. The same dependency applies at the __init__.py level — properties.register() must run before any other module that references these types.
Wiring Up the Panel
Now that the properties exist, we can uncomment the template_list() call in panels.py and update the override sub-panel to display real fields.
template_list() — Uncommented
In panels.py, find the commented-out block and replace it:
# Before (in BATCHRENDER_PT_queue.draw):
# layout.template_list(
# "BATCHRENDER_UL_queue", "",
# scene, "batch_render_jobs",
# scene, "batch_render_active_job_index",
# rows=4,
# )
# After:
layout.template_list(
"BATCHRENDER_UL_queue", "",
scene, "batch_render_jobs",
scene, "batch_render_active_job_index",
rows=4,
)PythonThe four arguments in the middle are what changed from placeholders to reality: scene (a bpy.types.Scene instance), "batch_render_jobs" (the name of our CollectionProperty on Scene), scene again (where the active index lives), and "batch_render_active_job_index" (the name of our index property). These match exactly what we registered in properties.py.
The UIList Row
While we’re in panels.py, let’s update BATCHRENDER_UL_queue.draw_item() to use real fields:
class BATCHRENDER_UL_queue(bpy.types.UIList):
"""Draws each render job as a row in the queue list."""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
if self.layout_type in {'DEFAULT', 'COMPACT'}:
row = layout.row(align=True)
# Toggle checkbox for enabled/disabled
row.prop(item, "enabled", text="")
# Status icon
status_icons = {
'PENDING': 'HANDLETYPE_FREE_VEC',
'RUNNING': 'PLAY',
'DONE': 'CHECKMARK',
'FAILED': 'ERROR',
}
icon = status_icons.get(item.status, 'NONE')
row.label(text=item.name, icon=icon)
elif self.layout_type == 'GRID':
layout.alignment = 'CENTER'
layout.label(text="", icon='RENDER_STILL')PythonThe row.prop(item, "enabled", text="") draws a checkbox directly in the row. The empty text="" suppresses the label — we just want the checkbox, not a label next to it. Each row now shows: a checkbox, a status icon, and the job name.
The Override Sub-Panel
Replace the placeholder label in BATCHRENDER_PT_job_overrides.draw() with real fields:
def draw(self, context):
layout = self.layout
scene = context.scene
jobs = scene.batch_render_jobs
index = scene.batch_render_active_job_index
if not jobs or index >= len(jobs):
layout.label(text="No job selected.", icon='INFO')
return
job = jobs[index]
# --- Resolution ---
box = layout.box()
row = box.row()
row.prop(job, "override_resolution", text="Resolution Override")
sub = box.column()
sub.enabled = job.override_resolution
sub.prop(job, "res_x")
sub.prop(job, "res_y")
# --- Samples ---
box = layout.box()
row = box.row()
row.prop(job, "override_samples", text="Samples Override")
sub = box.column()
sub.enabled = job.override_samples
sub.prop(job, "samples")
# --- Output Path ---
box = layout.box()
row = box.row()
row.prop(job, "override_output", text="Output Path Override")
sub = box.column()
sub.enabled = job.override_output
sub.prop(job, "output_path")
# --- Frame Range ---
box = layout.box()
box.label(text="Frame Range")
row = box.row(align=True)
row.prop(job, "frame_start")
row.prop(job, "frame_end")PythonThere’s a pattern here worth understanding: the sub.enabled = job.override_resolution line doesn’t hide the fields when the override is off — it grays them out. This is a deliberate UX choice. The user can see what value would be used if they enabled the override, and it’s obvious what the checkbox controls. Hidden fields create confusion; grayed-out fields communicate clearly.
The box = layout.box() call creates a visual separator for each override group. Boxes are a lightweight way to communicate grouping without a sub-panel per setting.
Updating the Operators
Now that the queue data is real, let’s update the poll() stubs in operators.py and wire up add_job and remove_job to actually modify the collection.
add_job — Add a Real Item
class BATCHRENDER_OT_add_job(bpy.types.Operator):
"""Add a new render job to the queue"""
bl_idname = "batch_render.add_job"
bl_label = "Add Job"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
jobs = context.scene.batch_render_jobs
new_job = jobs.add()
new_job.name = f"Job {len(jobs)}"
new_job.status = 'PENDING'
# Set the active index to the new item
context.scene.batch_render_active_job_index = len(jobs) - 1
return {'FINISHED'}Pythonjobs.add() appends a new RenderJobItem to the collection and returns it. We then set its name and status before returning. The CollectionProperty takes care of all the Blender-side bookkeeping — undo, saving to .blend, RNA notification.
remove_job — Remove the Selected Item
class BATCHRENDER_OT_remove_job(bpy.types.Operator):
"""Remove the selected job from the queue"""
bl_idname = "batch_render.remove_job"
bl_label = "Remove Job"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return len(context.scene.batch_render_jobs) > 0
def execute(self, context):
jobs = context.scene.batch_render_jobs
index = context.scene.batch_render_active_job_index
jobs.remove(index)
# Clamp the active index to a valid range
context.scene.batch_render_active_job_index = max(0, min(index, len(jobs) - 1))
return {'FINISHED'}PythonThe poll() method now has a real condition: the remove button is only available when there’s at least one job in the queue. If the queue is empty, the button grays out.
After removing an item, we clamp the active index so it doesn’t end up pointing past the end of the list. max(0, min(index, len(jobs) - 1)) handles the edge cases — if we removed the last item, the index stays at 0.
start_queue and stop_queue — Updated Polls
@classmethod
def poll(cls, context):
# Start is available when there are pending jobs and the queue isn't running.
# We'll refine "is running" in Post 7 when we add the modal operator.
jobs = context.scene.batch_render_jobs
return any(j.status == 'PENDING' and j.enabled for j in jobs)Python@classmethod
def poll(cls, context):
# Stop is available when at least one job is running.
# Full implementation in Post 7.
jobs = context.scene.batch_render_jobs
return any(j.status == 'RUNNING' for j in jobs)PythonThe stop_queue poll returns False for now because no job will have status == 'RUNNING' until the modal operator is in place in Post 7 — but the logic is correct and it’ll activate automatically once that piece exists.
retry_job — Updated Poll
@classmethod
def poll(cls, context):
jobs = context.scene.batch_render_jobs
index = context.scene.batch_render_active_job_index
if not jobs or index >= len(jobs):
return False
return jobs[index].status == 'FAILED'Pythonretry_job — Execute
def execute(self, context):
jobs = context.scene.batch_render_jobs
index = context.scene.batch_render_active_job_index
jobs[index].status = 'PENDING'
self.report({'INFO'}, f"Job '{jobs[index].name}' reset to PENDING")
return {'FINISHED'}PythonThe Complete properties.py
import bpy
JOB_STATUS_ITEMS = [
('PENDING', "Pending", "Waiting to be rendered"),
('RUNNING', "Running", "Currently rendering"),
('DONE', "Done", "Rendered successfully"),
('FAILED', "Failed", "Render failed or was cancelled"),
]
class RenderJobItem(bpy.types.PropertyGroup):
"""Represents a single render job in the queue."""
name: bpy.props.StringProperty(
name="Job Name",
description="A label for this render job",
default="Untitled Job",
)
filepath: bpy.props.StringProperty(
name="File Path",
description="Path to the .blend file to render",
default="",
subtype='FILE_PATH',
)
enabled: bpy.props.BoolProperty(
name="Enabled",
description="Include this job in the queue run",
default=True,
)
status: bpy.props.EnumProperty(
name="Status",
description="Current state of this render job",
items=JOB_STATUS_ITEMS,
default='PENDING',
)
override_resolution: bpy.props.BoolProperty(
name="Override Resolution",
description="Use a custom resolution for this job instead of the scene default",
default=False,
)
res_x: bpy.props.IntProperty(
name="Resolution X",
description="Override render width in pixels",
default=1920,
min=4,
soft_max=7680,
)
res_y: bpy.props.IntProperty(
name="Resolution Y",
description="Override render height in pixels",
default=1080,
min=4,
soft_max=4320,
)
override_samples: bpy.props.BoolProperty(
name="Override Samples",
description="Use a custom sample count for this job",
default=False,
)
samples: bpy.props.IntProperty(
name="Samples",
description="Override render sample count",
default=128,
min=1,
soft_max=4096,
)
override_output: bpy.props.BoolProperty(
name="Override Output Path",
description="Use a custom output path for this job",
default=False,
)
output_path: bpy.props.StringProperty(
name="Output Path",
description="Override output directory or file path",
default="",
subtype='DIR_PATH',
)
frame_start: bpy.props.IntProperty(
name="Frame Start",
description="First frame to render for this job",
default=1,
min=0,
)
frame_end: bpy.props.IntProperty(
name="Frame End",
description="Last frame to render for this job",
default=250,
min=0,
)
classes = [
RenderJobItem,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.batch_render_jobs = bpy.props.CollectionProperty(
type=RenderJobItem,
name="Render Queue",
description="List of render jobs in the batch queue",
)
bpy.types.Scene.batch_render_active_job_index = bpy.props.IntProperty(
name="Active Job Index",
description="Index of the currently selected render job",
default=0,
)
def unregister():
del bpy.types.Scene.batch_render_jobs
del bpy.types.Scene.batch_render_active_job_index
for cls in reversed(classes):
bpy.utils.unregister_class(cls)PythonRegistration Order
This is a good moment to revisit our __init__.py registration order, because it matters now in a concrete way.
RenderJobItem must be a registered class before we can reference it in CollectionProperty(type=RenderJobItem). And that CollectionProperty needs to be on bpy.types.Scene before the panel tries to call template_list() pointing at it. The current order in __init__.py handles this:
def register():
properties.register() # RenderJobItem registered; Scene props attached
operators.register() # Operators can now read context.scene.batch_render_jobs
panels.register() # Panels can now call template_list pointing at the real data
preferences.register()PythonIf you swap panels.register() above properties.register(), Blender will throw an error when it tries to draw the panel before the properties exist. The reverse unregister order (panels → operators → properties) ensures panels are cleaned up before the properties they depend on disappear.
Testing It
Reload the add-on. Open the N-panel in the 3D viewport (N), go to the Batch Render tab.
You should now be able to:
- Click + to add jobs to the queue — each one appears as a row in the list
- Click a row to select it
- Open the Job Overrides sub-panel and see the override fields for the selected job
- Toggle an override checkbox — the fields below it should go from grayed-out to active
- Click − to remove the selected job — the Remove button should gray out when the queue is empty
- Save your
.blendfile, reopen it, and find the queue exactly as you left it
That last one is worth trying. Open the Python console and inspect the queue to verify:
import bpy
for job in bpy.context.scene.batch_render_jobs:
print(job.name, job.status, job.filepath)PythonIf your jobs survive a save/reload, the data model is working correctly.
What We Have Now
Let’s take stock of where we are after five posts:
- A complete data model:
RenderJobItemwith identity, status, and per-job override fields - The queue attached to
bpy.types.Sceneso it persists with the file template_list()wired to real data — the queue list is live- Override fields displayed and conditionally enabled in the sub-panel
poll()methods on all five operators now check real stateadd_jobandremove_jobmodifying the actual collection- The full add-on — operators, panels, and properties — working together for the first time
The queue is real. The UI is real. The data persists.
What’s Coming Next
In Post 6, we’ll build queue_runner.py — the piece that actually executes renders. We’ll use Blender’s command-line rendering capability (blender --background) to render each .blend file as a subprocess, and we’ll use the --python-expr flag to inject our per-job override settings directly into the render without modifying the source file. That’s where Feature 2 — per-item setting overrides — gets completed end-to-end.
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.
