Developer coding Python on the left, Blender render jobs panel with property fields on the right

Blender Add-on Properties: Building a Data Model with Python

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)
Python

You 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:

  1. RenderJobItem — one instance per queue entry. Holds the job’s file path, label, current status, and per-job override settings.

  2. We’ll attach a CollectionProperty of RenderJobItem instances — plus an active index — to bpy.types.Scene. That’s what the template_list() call will point at.

Let’s think through the fields on RenderJobItem:

FieldTypePurpose
nameStringPropertyHuman-readable label for the job
filepathStringPropertyPath to the .blend file to render
statusEnumPropertyOne of: PENDING, RUNNING, DONE, FAILED
enabledBoolPropertyWhether this job is included in the queue run
override_resolutionBoolPropertyToggle: use the override resolution for this job
res_xIntPropertyOverride resolution X
res_yIntPropertyOverride resolution Y
override_samplesBoolPropertyToggle: use the override sample count
samplesIntPropertyOverride sample count
override_outputBoolPropertyToggle: use the override output path
output_pathStringPropertyOverride output path
frame_startIntPropertyStart frame for this job
frame_endIntPropertyEnd 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)
Python

A 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).

  • identifier is the internal value stored in Python — what you’ll compare against in code: if job.status == 'FAILED'
  • label is what the user sees in the UI
  • description is 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,
    ...
)
Python

This 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,
)
Python

The 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')
Python

The 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")
Python

There’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'}
Python

jobs.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'}
Python

The 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)
Python

The 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'
Python

retry_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'}
Python

The 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)
Python

Registration 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()
Python

If 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 (panelsoperatorsproperties) 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 .blend file, 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)
Python

If 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: RenderJobItem with identity, status, and per-job override fields
  • The queue attached to bpy.types.Scene so 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 state
  • add_job and remove_job modifying 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.

Visited 7 times, 1 visit(s) today

Leave a Reply

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