Define and bind Blender custom properties via bpy.props using the type annotation form, with PropertyGroup for grouping, PointerProperty for binding, and the four storage location options for Scene/Object/WindowManager/AddonPreferences. Targets 5.2 LTS.
Install
npx skillscat add tmhsdigital/blender-developer-tools/custom-properties Install via the SkillsCat registry.
Custom Properties
Trigger
Use this skill when the user:
- Wants to attach data to objects, scenes, or other Blender datablocks that survives save and load
- Mentions
bpy.props,PropertyGroup,PointerProperty, "custom properties" - Is unsure where to store add-on settings
- Sees a deprecation warning about property assignment
Required inputs
- What you want to store: a single number, a small struct, a list of items, or a complex hierarchy
- Where it should live: Scene (per-document), Object (per-object), WindowManager (session-only), AddonPreferences (per-user)
- Whether it must persist through save/load (almost always yes)
The type annotation form (mandatory since 2.8)
import bpy
class MY_ADDON_PG_settings(bpy.types.PropertyGroup):
intensity: bpy.props.FloatProperty(
name="Intensity",
description="How strong the effect is",
default=1.0,
min=0.0,
max=10.0,
)
use_smoothing: bpy.props.BoolProperty(name="Smooth", default=True)
mode: bpy.props.EnumProperty(
name="Mode",
items=[
('FAST', "Fast", "Lower quality, faster"),
('GOOD', "Good", "Balanced"),
('BEST', "Best", "Best quality, slower"),
],
default='GOOD',
)The properties are declared with : (PEP 526 annotations), not = (assignment). Blender's metaclass scans the annotation namespace and registers each bpy.props.* it finds.
The assignment form (intensity = bpy.props.FloatProperty(...)) is deprecated 2.8+ syntax. It silently does the wrong thing in some Blender versions and is rejected in others. Always use annotations.
See the rule type-annotate-props-and-defend-context for the rationale and the lint pattern.
The bpy.props types
| Type | Stores |
|---|---|
BoolProperty |
One bool |
IntProperty |
One signed int |
FloatProperty |
One float |
StringProperty |
A unicode string, optionally a subtype='FILE_PATH' |
EnumProperty |
One choice from a fixed list, with optional options={'ENUM_FLAG'} for multi-select |
BoolVectorProperty |
A fixed-size array of bools (size up to 32) |
IntVectorProperty |
A fixed-size array of ints |
FloatVectorProperty |
A fixed-size array of floats, often with subtype='COLOR' or subtype='TRANSLATION' |
PointerProperty |
A reference to another PropertyGroup (or built-in datablock type) |
CollectionProperty |
An ordered, named list of PropertyGroup instances |
Use subtype to get UI semantics for free: 'COLOR' opens a color picker, 'FILE_PATH' opens a file dialog, 'TRANSLATION'/'XYZ'/'EULER' get the right unit display.
Where to store the data
Custom properties are bound to a Blender type as a class attribute:
bpy.types.Scene.my_addon = bpy.props.PointerProperty(type=MY_ADDON_PG_settings)This makes bpy.data.scenes['Scene'].my_addon.intensity work for every Scene in the file.
The four conventional storage locations and their tradeoffs:
| Location | Persistence | Scope | Use for |
|---|---|---|---|
bpy.types.Scene.x |
Saved with the .blend | Per-Scene (per-document) | Add-on state that should travel with the file (export settings, custom render passes, baked data references) |
bpy.types.Object.x |
Saved with the .blend | Per-Object | Per-object metadata (constraint config, custom rig data, asset tags) |
bpy.types.WindowManager.x |
Session-only, not saved | Global to the running Blender | Transient state (last used path, current modal step, temp UI flags) |
AddonPreferences |
Saved with the user preferences | Per-user, global | API keys, default paths, "always do X" preferences |
You can also bind to bpy.types.Mesh, bpy.types.Material, bpy.types.Armature, etc. for datablock-specific data.
Picking storage
- Will the user expect to save and reopen the file with these values? Yes ->
SceneorObject. - Per-document or per-object? Per-document ->
Scene. Per-object ->Object. - User-global, like an API key? ->
AddonPreferences. - Transient session state, like the current step of a wizard? ->
WindowManager.
The PropertyGroup plus PointerProperty pattern
Group your add-on's settings into one PropertyGroup and bind that group via a single PointerProperty:
class MY_ADDON_PG_settings(bpy.types.PropertyGroup):
intensity: bpy.props.FloatProperty(default=1.0)
use_smoothing: bpy.props.BoolProperty(default=True)
def register():
bpy.utils.register_class(MY_ADDON_PG_settings)
bpy.types.Scene.my_addon = bpy.props.PointerProperty(type=MY_ADDON_PG_settings)
def unregister():
del bpy.types.Scene.my_addon
bpy.utils.unregister_class(MY_ADDON_PG_settings)You then access:
scene = context.scene
scene.my_addon.intensity = 2.0This is far cleaner than binding individual FloatPropertys directly to Scene. It also gives you a clear unregister target.
del first, then unregister_class
The order matters. The bound PointerProperty references the PropertyGroup class. If you unregister the class first, the binding becomes dangling and Blender crashes on next save. Always:
def unregister():
del bpy.types.Scene.my_addon
bpy.utils.unregister_class(MY_ADDON_PG_settings)Unbinding a type-level property is del bpy.types.Scene.my_addon on all Blender versions. See the snippet cross-version-property-delete.py for removing a custom ID property (also del, version-stable).
CollectionProperty (lists)
class MY_ADDON_PG_item(bpy.types.PropertyGroup):
name: bpy.props.StringProperty()
enabled: bpy.props.BoolProperty(default=True)
class MY_ADDON_PG_settings(bpy.types.PropertyGroup):
items: bpy.props.CollectionProperty(type=MY_ADDON_PG_item)
active_index: bpy.props.IntProperty(default=0)Add an item:
new_item = scene.my_addon.items.add()
new_item.name = "First"Remove by index:
scene.my_addon.items.remove(0)CollectionProperty plus a paired active_index: IntProperty is the canonical pattern for bpy.types.UIList integration.
AddonPreferences
For per-user, global data:
class MY_ADDON_AP_preferences(bpy.types.AddonPreferences):
bl_idname = __package__ # the add-on's package name
api_key: bpy.props.StringProperty(name="API Key", subtype='PASSWORD')
default_export_path: bpy.props.StringProperty(name="Default Export Path", subtype='DIR_PATH')
def draw(self, context):
layout = self.layout
layout.prop(self, "api_key")
layout.prop(self, "default_export_path")Access via:
prefs = context.preferences.addons[__package__].preferences
api_key = prefs.api_keyAddonPreferences data persists in the user's preferences (userpref.blend), not the current document. Do not store per-document state here.
Why "just use Python attributes" does not work
# WRONG: this attribute does not survive save and load.
bpy.context.scene.my_intensity = 1.5Properties attached as plain Python attributes to a Blender datablock are not serialized into the .blend file. The .blend file format only saves registered bpy.props. Reload the file and the value is gone.
This is a frequent AI mistake when generating quick scripts. Always wrap state in a PropertyGroup bound via PointerProperty if it must persist.
Common AI mistakes
Assignment form for properties:
class Bad(bpy.types.PropertyGroup): intensity = bpy.props.FloatProperty(default=0.5) # silently brokenSee the rule
type-annotate-props-and-defend-context.Storing complex state on Python attributes:
scene.my_data = {"items": [...]} # gone after save and reloadWrong unregister order (unregister class before deleting binding) -> crash on save.
Binding individual properties directly to Scene instead of grouping into a
PropertyGroup. Hard to unregister cleanly, pollutes the namespace.Storing per-document state in
AddonPreferences-> data leaks across files.Confusing
subtype='FILE_PATH'and'DIR_PATH'.'FILE_PATH'lets the user pick a file,'DIR_PATH'a directory.
Compatibility paths (4.5 LTS vs 5.0+)
Two distinct operations, both version-stable via del:
- Unbind a type-level property (e.g. a
PointerPropertybound tobpy.types.Scene):del bpy.types.Scene.my_addon. Same on 4.5 LTS and 5.0+. - Remove a custom ID property (the dict-style
obj["key"]):del obj["key"]. Same on 4.5 LTS and 5.0+. The snippetcross-version-property-delete.pyshows this.
Do not reach for property_unset() here. It resets a registered RNA property to its default, which is neither unbinding a type property nor removing an ID property.
Related
addon-scaffoldingfor the registration boilerplate that wraps property registrationui-panelsforlayout.propreading these properties- Rule
type-annotate-props-and-defend-context
References
bpy.propsreference: https://docs.blender.org/api/current/bpy.props.htmlbpy.types.PropertyGroup: https://docs.blender.org/api/current/bpy.types.PropertyGroup.htmlbpy.types.AddonPreferences: https://docs.blender.org/api/current/bpy.types.AddonPreferences.html