Today we wrote about 3DCodeVerse, a two-million-program dataset built on the premise that the right representation for a 3D scene is the code that generates it rather than the geometry it produces. The researchers are arguing it to machines. Artists have known it for years, and in Blender the entry point is bpy.
This is a first hour. No prior Python assumed.
When to reach for a script
Be honest about this first, because the failure mode is spending ninety minutes automating a nine-minute job.
Script it when:
- You are about to do the same thing more than roughly twenty times.
- The task has a number in it that you will want to change later (count, spacing, seed, angle).
- You need to touch every object in a scene and the pattern of which ones is a rule, not a selection.
- You want to rename, re-link, re-path or audit a file with hundreds of datablocks.
- You need the result to be reproducible — the same file, exactly, next month.
Do not script it when: Geometry Nodes already does it. Nodes are faster to iterate, live-update, and survive being handed to someone who does not write code. Scripting beats nodes for scene assembly, file wrangling, batch operations and anything that needs to touch Blender’s data outside the modifier stack. It loses for geometry.
Step 1: the Scripting workspace
Top bar → the Scripting tab. You get four things that matter:
- Python Console — a live prompt into the running Blender. Type, get an answer immediately.
- Text Editor — where scripts live.
New, thenRun Script(or Alt+P). - Info — the log of everything you have done. This is the part people miss.
- Outliner / Properties — the normal scene views.
Start in the console. Type:
bpy.data.objects
and press Enter. You get a listing of every object in the file. You are already in.
Step 2: let Blender tell you the API
This is the single most useful habit in Blender scripting and it takes thirty seconds to learn.
Add a cube with the mouse: Add → Mesh → Cube. Now look at the Info editor. It printed:
bpy.ops.mesh.primitive_cube_add(size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
That is the exact call your click made, with every argument. Copy it into the Text Editor and it works.
Do the same for anything you do not know how to script: do it with the mouse, read Info, copy the line. You almost never need to search documentation to find which function — only to find out what its arguments mean.
Second half of the habit: right-click almost any field in the interface → Copy Full Data Path. That gives you the bpy.data... address of that specific property. Right-click a cube’s X location and you get something like bpy.data.objects["Cube"].location[0].
Two discovery tools, both built in, both pointing at different halves of the API. Which brings us to the thing that confuses everybody.
Step 3: bpy.ops versus bpy.data — the one distinction that matters
bpy.ops is doing what the user does. It runs operators — the same code the buttons run. It depends on context: what is selected, which mode you are in, which editor the mouse is over. bpy.ops.object.delete() deletes the selection.
bpy.data is reaching into the file. It addresses datablocks directly and does not care what is selected. bpy.data.objects["Cube"].location = (1, 2, 3) moves that cube whatever is selected.
The practical rule, learned by everyone the hard way:
Prefer
bpy.datawhenever you can. Usebpy.opsonly for things that have no data equivalent.
Why: bpy.ops calls are slow (each one pushes an undo step and re-evaluates), and they break the moment the context is not what you assumed — which is exactly what happens when you run the same script from a menu entry instead of the Text Editor. A loop of 500 bpy.ops.mesh.primitive_cube_add() calls will take visible seconds and may fail outside the 3D viewport. Building 500 objects through bpy.data is instant and context-free.
So: discover with bpy.ops from the Info log, then rewrite in bpy.data once you know what you are doing.
Step 4: your first real script
A hundred cubes on a grid, with a wobble, as data:
import bpy
import math
COUNT = 10 # per side
SPACING = 2.5
WOBBLE = 0.35
# Make one cube the easy way, then reuse its mesh for every object:
# 100 objects, 1 mesh datablock.
bpy.ops.mesh.primitive_cube_add(size=1)
scratch = bpy.context.active_object
scratch.name = "Scratch"
mesh = scratch.data
mesh.name = "GridCube"
collection = bpy.data.collections.new("Grid")
bpy.context.scene.collection.children.link(collection)
for x in range(COUNT):
for y in range(COUNT):
obj = bpy.data.objects.new(f"Cube_{x}_{y}", mesh)
obj.location = (
x * SPACING,
y * SPACING,
math.sin(x * 0.6) * math.cos(y * 0.6) * WOBBLE * SPACING,
)
obj.rotation_euler = (0, 0, (x + y) * 0.1)
collection.objects.link(obj)
bpy.data.objects.remove(scratch, do_unlink=True)
Read what that does, because the shape generalises to most useful scripts:
- One
bpy.opscall, used once, to get geometry Blender already knows how to make. bpy.data.objects.new(name, mesh)creates an object without touching the scene — and crucially, a hundred objects sharing one mesh. Doing this withprimitive_cube_addin the loop gives you a hundred separate meshes and a bloated file.collection.objects.link(obj)is what actually puts it in the scene. New objects are not in the scene until linked, which catches everybody once.- Named constants at the top. Change
COUNTto 40 and re-run. This is the entire reason to be here.
Run it. Change three numbers. Run it again. That loop is the payoff.
Step 5: iterating over what already exists
The other half of practical scripting is auditing and fixing a file you did not make:
import bpy
# Every mesh object with no material
for obj in bpy.data.objects:
if obj.type == 'MESH' and not obj.data.materials:
print(f"no material: {obj.name}")
# Rename by prefix
for obj in bpy.data.objects:
if obj.type == 'MESH' and not obj.name.startswith("SM_"):
obj.name = "SM_" + obj.name
# Find every image whose file is missing
for img in bpy.data.images:
if img.source == 'FILE' and not img.packed_file and img.size[0] == 0:
print(f"broken texture: {img.name} -> {img.filepath}")
Three snippets, each solving a real problem that is miserable by hand in a file with four hundred objects. Note that none of them select anything, change mode, or depend on where the mouse is.
Step 6: things that will bite you
bpy.context.active_objectisNonemore often than you expect — nothing selected, or wrong editor. Guard it.- Deleting while iterating.
for obj in bpy.data.objects: bpy.data.objects.remove(obj)mutates the collection you are walking. Build a list first:for obj in list(bpy.data.objects):. - Units.
primitive_cube_add(size=2)gives a 2 m cube, so the default cube is 2 m across, not 1. Scale values multiply on top. - Radians, not degrees.
rotation_euleris radians.math.radians(45). - The API changes between versions. The
bpyreference is versioned for a reason; a script from a 3.x tutorial may use an operator argument that no longer exists. When something errors with an unexpected keyword, check the current docs for that operator rather than assuming you typo’d. - Undo does not always cover
bpy.dataedits the way it covers operator calls. Save before running something destructive.
Where to go next
Three natural directions, in order of usefulness:
- Drivers and custom properties — scripted relationships that live in the file and need no script to run.
- Operators and panels — wrapping your script in a button, with
bpy.types.Operatorandbpy.types.Panel, which is how an add-on is born. - Headless rendering —
blender --background file.blend --python script.py, which turns a script into a pipeline step and is where scripting starts paying for itself on real deadlines.
The official references are the ones to bookmark: the Python API quickstart is short and worth reading end to end, and the manual’s scripting introduction explains how the pieces fit together.
Sources
- Getting Started with Scripting in Python — Blender (official channel)
- Blender Python API quickstart
- bpy.ops reference
- Scripting & extending Blender — Blender Manual