A developer writing Python code with a Blender viewport showing custom UI operators

Blender Operators: Teaching Your Python Add-on What It Can Do

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


In this series: We’re building a Batch Render Manager add-on from scratch. Post 1 introduced the operator basics and got a Hello World add-on running. Post 2 converted it into a proper Python package with a clean six-file structure. This post builds out the first real functionality — five operators that will drive the render queue.


In the last post, we turned our single-file Hello World into a proper Python package. We’ve got six files, a clean structure, and an add-on that installs without complaint — even if it doesn’t do anything yet.

That changes today. We’re going to build out operators.py and add the first real functionality to the Batch Render Manager: the ability to add jobs to a render queue, remove them, start the queue, stop it, and retry a failed job.

Along the way, we’ll dig into some operator features you’ll use in almost every add-on you write: poll() methods for controlling when an operator is available, invoke() for handling user input before executing, and how Blender’s undo system works with operators.


What Is an Operator, Really?

We touched on this in Post 1, but let’s be more precise.

An operator in Blender is a discrete, undo-able action. Nearly every user-facing action in Blender — from moving an object, deleting a mesh, to changing a render setting — is implemented as an operator under the hood. When you press G to grab an object, you’re invoking transform.translate. When you click the Render button, you’re invoking render.render.

Your add-on’s operators work the same way. They show up in the search menu (F3), they can be assigned to shortcuts, they log in the Info bar, and they participate in the undo stack.

Every operator is a Python class that inherits from bpy.types.Operator and defines at minimum:

  • bl_idname — the unique identifier used to call it
  • bl_label — the human-readable name
  • execute(self, context) — the method that does the work

We saw all of this in Post 1. Now we’re going to layer in the more interesting stuff.


The Operators We’re Building

For the queue system, we will build five operators:

Operatorbl_idnameWhat It Does
Add Jobbatch_render.add_jobAdds a new item to the queue
Remove Jobbatch_render.remove_jobRemoves the selected queue item
Start Queuebatch_render.start_queueBegins processing the render queue
Stop Queuebatch_render.stop_queueCancels an in-progress queue
Retry Jobbatch_render.retry_jobResets a failed job so it can run again

We’re not going to be able to hook these up end-to-end yet, because the actual queue data won’t be built until Post 5, and the render runner comes in Post 6. But we can write the operators fully, with all their logic stubbed out in a way that’s clear and ready to connect.


Building operators.py

Let’s open operators.py and start writing. We’ll build each operator in turn, then wire up registration at the bottom.

Add Job

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):
        # We'll connect this to the actual queue property in Post 5.
        # For now, just confirm the operator runs.
        self.report({'INFO'}, "Job added to queue")
        return {'FINISHED'}
Python

Notice bl_options = {'REGISTER', 'UNDO'}. Let’s talk about what that means.

'REGISTER' tells Blender to log this operator in the Info bar (the header at the very top of the screen that shows recent actions). It also makes the operator show up when you search with F3. For anything the user explicitly triggers, you want this.

'UNDO' pushes an undo step when the operator finishes. This means the user can Ctrl+Z to reverse it. Not every operator needs undo support — a purely informational operator (like one that just prints something) doesn’t modify data, so there’s nothing to undo. But for any operator that changes state, it’s a good idea.

See the Blender Python API reference for the full list of potential bl_options.

Remove Job

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):
        # Only available if there are jobs in the queue.
        # We'll replace this with a real queue check in Post 5.
        return True

    def execute(self, context):
        self.report({'INFO'}, "Job removed from queue")
        return {'FINISHED'}
Python

We have a poll() method here — we’ll get into this properly in a moment. For now it just returns True.

Start Queue

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):
        # Only available when we have jobs and aren't already running.
        return True

    def execute(self, context):
        self.report({'INFO'}, "Queue started")
        return {'FINISHED'}
Python

No 'UNDO' here — starting a render queue isn’t something you’d want to undo with Ctrl+Z. The undo stack is for data changes, not process control.

Stop Queue

class BATCHRENDER_OT_stop_queue(bpy.types.Operator):
    """Stop the currently running render queue"""
    bl_idname = "batch_render.stop_queue"
    bl_label = "Stop Queue"
    bl_options = {'REGISTER'}

    @classmethod
    def poll(cls, context):
        # Only available when the queue is actively running.
        return True

    def invoke(self, context, event):
        return context.window_manager.invoke_confirm(self, event)

    def execute(self, context):
        self.report({'INFO'}, "Queue stopped")
        return {'FINISHED'}
Python

Retry Job

class BATCHRENDER_OT_retry_job(bpy.types.Operator):
    """Reset a failed job so it can be run again"""
    bl_idname = "batch_render.retry_job"
    bl_label = "Retry Job"
    bl_options = {'REGISTER', 'UNDO'}

    @classmethod
    def poll(cls, context):
        # Only available when the selected job has a FAILED status.
        return True

    def execute(self, context):
        self.report({'INFO'}, "Job queued for retry")
        return {'FINISHED'}
Python

Registration

At the bottom of operators.py, we register them all:

classes = [
    BATCHRENDER_OT_add_job,
    BATCHRENDER_OT_remove_job,
    BATCHRENDER_OT_start_queue,
    BATCHRENDER_OT_stop_queue,
    BATCHRENDER_OT_retry_job,
]

def register():
    for cls in classes:
        bpy.utils.register_class(cls)

def unregister():
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)
Python

Naming Conventions

Before we go further, let’s talk about that class name: BATCHRENDER_OT_add_job. That’s not just a quirky stylistic choice — Blender has a naming convention for add-on classes, and following it matters.

The format is: PREFIX_TYPE_description

  • PREFIX — a short, uppercase identifier for your add-on. We’re using BATCHRENDER. This prevents name collisions if two add-ons both happen to define an operator called add_job.
  • TYPE — a two-letter code indicating what kind of Blender class this is:
    • OT — Operator
    • PT — Panel
    • MT — Menu
    • UL — UI List
    • PG — Property Group
    • AP — Addon Preferences
  • description — lowercase with underscores, describing what the class does

Blender won’t crash if you ignore this convention, but you’ll get warnings in newer versions, and it makes your code harder to navigate when you have dozens of classes.

The bl_idname follows a different convention: it uses a lowercase category.action format (batch_render.add_job), which is Blender’s built-in style for all operator IDs.


poll(): Controlling When an Operator Is Available

The poll() method is one of the most useful patterns in Blender’s operator system, and it’s worth understanding properly.

poll() is a class method — notice the cls parameter instead of self — that Blender calls before the operator runs, to decide whether it should be available at all. A class method is called on the class instead of on an instance of the class. In this case, we want to know if this method is allowed to run in the current context. If poll() returns False, the button tied to that operator will be grayed out, and calling it via F3 or a shortcut will do nothing.

Here’s the @classmethod decorator it requires:

@classmethod
def poll(cls, context):
    return True  # or whatever condition makes sense
Python

That @classmethod decorator is easy to forget, and if you omit it, Blender will silently fail or throw a confusing error. Make it a habit to add it any time you write poll().

For our operators, the conditions will be things like:

  • Remove Job — is there a queue item currently selected?
  • Start Queue — are there jobs in the queue, and is the queue not already running?
  • Stop Queue — is the queue actively running right now?
  • Retry Job — does the selected job have a FAILED status?

We’re stubbing these with return True for now because the queue data doesn’t exist yet. In Post 5, when we build out the PropertyGroup that holds our queue items, we’ll come back and replace these stubs with real checks.


invoke(): Asking Before Acting

There’s a third method you’ll encounter often: invoke(). It sits between the user clicking a button and execute() running.

The most common use is to show a confirmation dialog before a destructive action:

def invoke(self, context, event):
    return context.window_manager.invoke_confirm(self, event)
Python

When this is present, clicking the button will pop up an “OK?” dialog. The user clicks OK, and then execute() runs.

For our Batch Render Manager, BATCHRENDER_OT_stop_queue is a good candidate. Stopping an in-progress render queue means abandoning work in progress, so a quick confirmation makes sense — and we already have it wired up in the class above.

invoke() must return a set, just like execute(). The options are:

  • {'FINISHED'} — done, no further interaction
  • {'CANCELLED'} — the user dismissed it
  • {'RUNNING_MODAL'} — the operator is now in modal mode (more on this in Post 7)
  • {'PASS_THROUGH'} — pass the event to the next handler

invoke_confirm() handles the return value for you, so you don’t have to think about it here. Under the hood, it returns {'RUNNING_MODAL'} while the dialog is open and {'FINISHED'} or {'CANCELLED'} based on what the user clicks.


How report() Works

You’ve seen self.report({'INFO'}, "some message") in every operator we’ve written. Let’s clarify what this actually does.

report() sends a message to Blender’s Info system. The first argument is a set containing the message type, and the second is the message string. The types you’ll use most are:

  • {'INFO'} — neutral informational message, shows in blue
  • {'WARNING'} — something worth noting, shows in yellow
  • {'ERROR'} — something went wrong, shows in red and creates a popup

These messages appear in the Info bar at the top of the screen and are logged in the Info editor. For our queue runner, we’ll use {'ERROR'} to surface problems like a missing .blend file or a render that fails.

One thing to know: report() only works during execute() or invoke(). You can’t call it in poll() — that runs too early and too often.


The Full operators.py

Here’s the complete file, with all five operators and imports included:

import bpy

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):
        self.report({'INFO'}, "Job added to queue")
        return {'FINISHED'}

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 True

    def execute(self, context):
        self.report({'INFO'}, "Job removed from queue")
        return {'FINISHED'}

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):
        return True

    def execute(self, context):
        self.report({'INFO'}, "Queue started")
        return {'FINISHED'}

class BATCHRENDER_OT_stop_queue(bpy.types.Operator):
    """Stop the currently running render queue"""
    bl_idname = "batch_render.stop_queue"
    bl_label = "Stop Queue"
    bl_options = {'REGISTER'}

    @classmethod
    def poll(cls, context):
        return True

    def invoke(self, context, event):
        return context.window_manager.invoke_confirm(self, event)

    def execute(self, context):
        self.report({'INFO'}, "Queue stopped")
        return {'FINISHED'}

class BATCHRENDER_OT_retry_job(bpy.types.Operator):
    """Reset a failed job so it can be run again"""
    bl_idname = "batch_render.retry_job"
    bl_label = "Retry Job"
    bl_options = {'REGISTER', 'UNDO'}

    @classmethod
    def poll(cls, context):
        return True

    def execute(self, context):
        self.report({'INFO'}, "Job queued for retry")
        return {'FINISHED'}

classes = [
    BATCHRENDER_OT_add_job,
    BATCHRENDER_OT_remove_job,
    BATCHRENDER_OT_start_queue,
    BATCHRENDER_OT_stop_queue,
    BATCHRENDER_OT_retry_job,
]

def register():
    for cls in classes:
        bpy.utils.register_class(cls)

def unregister():
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)
Python

Testing the Operator

With our new code, we are ready to test. Reload the add-on (disable and re-enable in Preferences, or F8 in Blender, or the reload command via the Blender Development extension).

Go to the Python console on the Scripting tab and type:

import bpy

dir(bpy.ops.batch_render)
Python

You should see a list with our five operators:

['add_job', 'remove_job', 'retry_job', 'start_queue', 'stop_queue']
Python

Then try calling one directly:

bpy.ops.batch_render.add_job()
Python

You should see:

Info: Job added to queue
{'FINISHED'}
Python

What We Have Now

At the end of this post, we have:

  • Five operators covering the core actions of the Batch Render Manager
  • A solid understanding of bl_options, poll(), invoke(), and report()
  • Blender’s class naming convention locked in
  • Everything registered and callable from the Python console

The operators are stubbed — they report messages but don’t actually touch any queue data yet. That’s intentional. We’re building the pieces in order, and the data model comes next.


What’s Coming Next

In Post 4, we’ll build the panel that gives these operators a home in the UI. We’ll put a queue list panel in the Properties editor’s Render tab, add toolbar buttons for our operators, and stub out the per-item sub-panel that will eventually show override settings. We’ll also cover UIList — Blender’s way of rendering scrollable, selectable lists of items.

Once the panel is in place, the add-on will actually look like something, even if the queue itself is still empty. That’s a satisfying milestone to hit.

See you there.


Have questions or ran into a snag? Drop a comment below. If you are interested in more content Subscribe to the newsletter — I share work-in-progress updates, upcoming tutorials, and early looks at what I’m building.

Visited 1 times, 1 visit(s) today

Leave a Reply

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