Skip to content
Back to skills

Operators

ASecurity

Author bpy.types.Operator classes: bl_idname naming, poll / invoke / execute / modal lifecycle, bl_options (REGISTER, UNDO, INTERNAL, BLOCKING), operator properties and defensive context handling. Use when the user creates an operator for a button, menu, keymap or script, asks why its properties are missing from the Adjust Last Operation (redo) panel or why Ctrl-Z ignores it, or gets poll failures or None context objects. Targets 5.2 LTS with 4.5 LTS compatibility.

  • 16 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 23, 2026
developmentpythongogitapi

Works with

  • cursor
  • api

Security analysis

A100/100

Scanned October 5, 2026

npx -y skills add TMHSDigital/Blender-Developer-Tools --skill operators --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Operators?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Operators
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/tmhsdigital-operators/badge)](https://www.skillsdirectory.com/skills/tmhsdigital-operators)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: operators
description: "Author bpy.types.Operator classes: bl_idname naming, poll / invoke / execute / modal lifecycle, bl_options (REGISTER, UNDO, INTERNAL, BLOCKING), operator properties and defensive context handling. Use when the user creates an operator for a button, menu, keymap or script, asks why its properties are missing from the Adjust Last Operation (redo) panel or why Ctrl-Z ignores it, or gets poll failures or None context objects. Targets 5.2 LTS with 4.5 LTS compatibility."
standards-version: 1.10.0
---

# Operators

## Trigger

Use this skill when the user:

- Wants to create a new `bpy.types.Operator`
- Mentions `bl_idname`, `bl_label`, `bl_options`, "redo panel", "F6 panel", "operator props"
- Needs an action triggered from a button, menu, keymap, or chat command
- Asks why their operator's properties don't show up in the redo panel

## Required inputs

- **Action category**: the prefix of `bl_idname` (`mesh`, `object`, `scene`, `view3d`, etc.)
- **Operator name**: the suffix (`my_addon_action`)
- **Class name**: the conventional `CATEGORY_OT_name` (`MESH_OT_my_addon_action`)
- **Whether the action is undoable** (most are)

## Anatomy of a Blender operator

```python
import bpy


class MESH_OT_offset_along_normals(bpy.types.Operator):
    bl_idname = "mesh.offset_along_normals"
    bl_label = "Offset Along Normals"
    bl_description = "Move every vertex of the active mesh a fixed distance along its normal"
    bl_options = {'REGISTER', 'UNDO'}

    distance: bpy.props.FloatProperty(
        name="Distance",
        description="How far to move each vertex",
        default=0.5,
        min=-100.0,
        max=100.0,
        unit='LENGTH',
    )

    @classmethod
    def poll(cls, context):
        obj = context.active_object
        return obj is not None and obj.type == 'MESH' and obj.mode == 'OBJECT'

    def execute(self, context):
        obj = context.active_object
        if obj is None:
            self.report({'ERROR'}, "No active object")
            return {'CANCELLED'}

        # Operate on bpy.data, not bpy.ops, and in bulk: one foreach_get /
        # foreach_set pair instead of a Python loop over mesh.vertices (rule
        # use-foreach-set-for-bulk-data). See the mesh-editing-and-bmesh skill.
        mesh = obj.data
        n = len(mesh.vertices)
        co = [0.0] * (n * 3)
        nor = [0.0] * (n * 3)
        mesh.vertices.foreach_get("co", co)
        mesh.vertices.foreach_get("normal", nor)
        d = self.distance
        mesh.vertices.foreach_set("co", [c + k * d for c, k in zip(co, nor)])

        mesh.update()
        self.report({'INFO'}, f"Offset {n} vertices by {d:.3f}")
        return {'FINISHED'}
```

## The lifecycle methods

| Method | Required | Called when | Returns |
| --- | --- | --- | --- |
| `poll(cls, context)` | Optional | Every UI redraw to decide if the operator is enabled. Must be cheap. | `True` to enable, `False` or falsy to disable |
| `invoke(self, context, event)` | Optional | The user triggers the operator interactively (button, menu, keymap). Default forwards to `execute`. | `{'FINISHED'}`, `{'CANCELLED'}`, `{'PASS_THROUGH'}`, `{'RUNNING_MODAL'}` |
| `execute(self, context)` | Yes | The operator runs, either after `invoke` or directly via `bpy.ops.mesh.offset_along_normals()` | `{'FINISHED'}` or `{'CANCELLED'}` |
| `modal(self, context, event)` | Optional | After `invoke` returns `{'RUNNING_MODAL'}`. Called for each event until you return `{'FINISHED'}` or `{'CANCELLED'}`. | Standard return set |
| `draw(self, context)` | Optional | The redo panel after the operator runs. Defaults to drawing all properties. | None |

### `poll` must be fast

`poll` runs on every UI redraw. **Do not iterate the scene, scan meshes, or call modifiers there.** Restrict yourself to `context.active_object`, `obj.type`, `obj.mode`, and similar O(1) checks.

### `bl_options` you almost always want

- `'REGISTER'`: reports the operator in the Info editor and enables the Adjust Last Operation (redo) panel. Without it, properties cannot be tweaked after running. It does not add an undo step; that is `'UNDO'`.
- `'UNDO'`: hooks into the undo stack. Without this, your operator's effect cannot be undone with Ctrl-Z.
- `'INTERNAL'`: hides the operator from operator search. Use for operators called only by other operators or panels. It still runs from Python and buttons.
- `'BLOCKING'`: blocks anything else from using the cursor while the operator runs. Reserve for modal operators that need exclusive mouse input.
- `'GRAB_CURSOR'` and `'GRAB_CURSOR_X'` / `'GRAB_CURSOR_Y'`: useful in modal operators to capture mouse motion.

## Defensive context handling

`context.active_object` returns `None` when the scene has no active object, which happens routinely:

- Headless scripts via `blender --background --python script.py` start with no active object.
- Empty selections after a deselect-all.
- Some context overrides set the active object to `None` deliberately.

**Always** guard before dereferencing:

```python
def execute(self, context):
    obj = context.active_object
    if obj is None:
        self.report({'ERROR'}, "No active object")
        return {'CANCELLED'}
    if obj.type != 'MESH':
        self.report({'ERROR'}, f"{obj.name} is a {obj.type}, expected MESH")
        return {'CANCELLED'}
    # ...
```

Even if `poll` already filtered, `execute` must re-check, because `execute` is also reachable from `bpy.ops` calls in scripts that bypass the UI.

## Reporting back to the user

Use `self.report({level}, message)` rather than `print()`:

| Level | UI behavior |
| --- | --- |
| `'DEBUG'` | Visible only in developer mode |
| `'INFO'` | Status bar, info window |
| `'WARNING'` | Status bar with yellow tint, info window |
| `'ERROR'` | Status bar with red tint, info window |
| `'ERROR_INVALID_INPUT'` | Same as ERROR but signals input was the cause |

Pair `report({'ERROR'}, ...)` with `return {'CANCELLED'}` so the operator does not appear successful.

## Worked example: a redo-friendly operator with a redo panel

```python
import bpy
from mathutils import Vector


class OBJECT_OT_offset_active(bpy.types.Operator):
    bl_idname = "object.offset_active"
    bl_label = "Offset Active Object"
    bl_options = {'REGISTER', 'UNDO'}

    offset_x: bpy.props.FloatProperty(name="X", default=0.0, unit='LENGTH')
    offset_y: bpy.props.FloatProperty(name="Y", default=0.0, unit='LENGTH')
    offset_z: bpy.props.FloatProperty(name="Z", default=1.0, unit='LENGTH')
    use_local: bpy.props.BoolProperty(
        name="Local Axes",
        description="Offset along object's local axes instead of world axes",
        default=False,
    )

    @classmethod
    def poll(cls, context):
        return context.active_object is not None

    def execute(self, context):
        obj = context.active_object
        if obj is None:
            return {'CANCELLED'}

        offset = (self.offset_x, self.offset_y, self.offset_z)

        if self.use_local:
            # Column-normalize the basis so object scale does not distort the
            # local-axis direction; to_3x3() alone carries scale into the result.
            basis = obj.matrix_world.to_3x3().normalized()
            obj.location += basis @ Vector(offset)
        else:
            obj.location.x += self.offset_x
            obj.location.y += self.offset_y
            obj.location.z += self.offset_z

        return {'FINISHED'}
```

After running this operator from the menu, the user gets a redo panel in the bottom-left of the 3D viewport with sliders for `offset_x/y/z` and a `use_local` toggle. Adjusting them re-runs `execute`. This works only because `'REGISTER'` is in `bl_options` and the properties are declared as **annotations**, not assignments (see the `custom-properties` skill).

## Common AI mistakes

1. **Returning `True` / `False` from `execute`**. The required return is the set `{'FINISHED'}` or `{'CANCELLED'}`. Returning a bool will raise.

2. **Forgetting `'UNDO'` in `bl_options`**. The operator runs but the user cannot reverse it.

3. **Slow `poll`** that walks the scene, evaluates the depsgraph, or imports modules. Causes UI lag.

4. **Properties as assignments**:

   ```python
   # WRONG: the property is class-level data, not a typed annotation
   class Bad(bpy.types.Operator):
       distance = bpy.props.FloatProperty(default=0.5)

   # RIGHT: annotation form, what 2.8+ requires
   class Good(bpy.types.Operator):
       distance: bpy.props.FloatProperty(default=0.5)
   ```

   The assignment form silently does not register the property in some Blender versions and breaks the redo panel in all of them.

5. **No `bl_idname`** or a `bl_idname` without a category prefix (`my_action` instead of `mesh.my_action`). Blender requires the `category.name` shape.

6. **Calling `bpy.ops.<other>` from `execute` for bulk work**. Each `bpy.ops` call triggers a depsgraph evaluation. Operate directly on `bpy.data` or via `bmesh` instead. See the `prefer-data-over-ops-in-loops` rule.

## Related

- `addon-scaffolding` for the registration pattern around your operator class
- `custom-properties` for the annotation form
- `mesh-editing-and-bmesh` for what to do inside `execute` for mesh work
- Rule `prefer-data-over-ops-in-loops`
- Rule `type-annotate-props-and-defend-context`

<!-- examples:begin (generated by scripts/build_examples_index.py from examples/skills.json; do not edit) -->
## Runnable examples

Each example runs headless, asserts the contract, and exits non-zero when it breaks. Run one with `blender --background --python <script> --`; pass a falsifier flag to watch the check fail.

- [`prop-origin-transform`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/prop-origin-transform): Street pedestal origin-to-base-center + data-API scale apply + matrix_parent_inverse for a flanged conduit elbow. Falsify: `--skip-mpi` (exit 8).
- [`temp-override-join`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/temp-override-join): Join seven lantern parts into one object under bpy.context.temp_override — the supported replacement for the removed context.copy() dict-pass form. Falsify: `--no-override` (exit 3).
- [`timers-modal-threading`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/timers-modal-threading): Proves the event-loop contracts behind long-running add-on work. Falsify: `--return-zero-once` (exit 5).

<!-- examples:end -->

## References

- `bpy.types.Operator`: https://docs.blender.org/api/current/bpy.types.Operator.html
- Operator example in the API docs: https://docs.blender.org/api/current/info_quickstart.html

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.