Rigging with Python in Maya

Character rigging is one of the most time-consuming tasks in a 3D pipeline. A single biped rig can take days to build manually - and then you need to do it again for the next character. Python scripting lets you automate rig construction, enforce consistency across characters, and rebuild rigs in seconds when specs change.

Why Script Your Rigs

Manual rigging is fragile. Every hand-placed constraint, every manually typed attribute name, every clicked checkbox is a potential point of failure. Scripted rigs give you:

  • Reproducibility - Run the script, get the same rig every time. No forgotten steps, no human error.
  • Speed - A rig that takes 8 hours to build manually can be generated in under 30 seconds with a script.
  • Iteration - When the animation lead wants changes to the spine setup, you modify one function and regenerate all character rigs.
  • Consistency - Every character in the project follows the same naming conventions, control shapes, and hierarchy structure.
  • Version control - Your rig definition lives in Python files that can be tracked with Git, diffed, and reviewed.
Note

The examples in this guide use maya.cmds for clarity and broad compatibility. In production, you might prefer PyMEL for cleaner syntax or the Python API for performance-critical operations.

Joint Chain Creation

Joints are the foundation of every rig. Let's build a function that creates joint chains from a list of positions - this is the building block for arms, legs, spines, fingers, and tails.

Python
import maya.cmds as cmds

def create_joint_chain(positions, names, orient_axis='xyz', up_axis='yup',
                       radius=1.0, parent=None):
    """
    Create a joint chain from a list of world-space positions.

    Args:
        positions: List of (x, y, z) tuples for each joint.
        names: List of names for each joint (must match length of positions).
        orient_axis: Primary axis for joint orient (e.g., 'xyz', 'xzy').
        up_axis: Secondary axis up direction ('xup', 'yup', 'zup').
        radius: Display radius for joints.
        parent: Optional parent node to place the chain under.

    Returns:
        List of joint names in chain order.
    """
    if len(positions) != len(names):
        cmds.warning('positions and names must be the same length')
        return []

    cmds.select(clear=True)
    joints = []

    for pos, name in zip(positions, names):
        jnt = cmds.joint(position=pos, name=name, radius=radius)
        joints.append(jnt)

    # Orient joints so the primary axis points to the child
    cmds.joint(joints[0], edit=True, orientJoint=orient_axis,
               secondaryAxisOrient=up_axis, children=True, zeroScaleOrient=True)

    # Zero out orientation on the end joint (no child to aim at)
    cmds.joint(joints[-1], edit=True, orientation=(0, 0, 0))

    # Parent to specified node
    if parent and cmds.objExists(parent):
        cmds.parent(joints[0], parent)

    return joints

# Create a 5-joint spine chain
spine_positions = [
    (0, 100, 0),    # spine base (hip height)
    (0, 110, -1),   # spine_01
    (0, 120, -1.5), # spine_02
    (0, 130, -1),   # spine_03
    (0, 140, 0),    # chest
]
spine_names = ['spine_base_JNT', 'spine_01_JNT', 'spine_02_JNT',
               'spine_03_JNT', 'chest_JNT']

spine_joints = create_joint_chain(spine_positions, spine_names, radius=2.0)
Python
import maya.cmds as cmds

def create_limb_joints(side='L', limb='arm', positions=None, radius=1.5):
    """
    Create a standard 3-joint limb chain (shoulder/hip, elbow/knee, wrist/ankle).

    Args:
        side: 'L' or 'R'.
        limb: 'arm' or 'leg'.
        positions: Optional list of 3 positions. Uses defaults if None.
        radius: Joint display radius.

    Returns:
        Dict with 'joints' list and 'root'/'mid'/'end' keys.
    """
    if limb == 'arm':
        default_positions = [
            (15 if side == 'L' else -15, 140, 0),   # shoulder
            (40 if side == 'L' else -40, 140, -5),   # elbow
            (65 if side == 'L' else -65, 140, 0),    # wrist
        ]
        part_names = ['shoulder', 'elbow', 'wrist']
    elif limb == 'leg':
        default_positions = [
            (10 if side == 'L' else -10, 95, 0),    # hip
            (10 if side == 'L' else -10, 50, 2),    # knee
            (10 if side == 'L' else -10, 5, 0),     # ankle
        ]
        part_names = ['hip', 'knee', 'ankle']
    else:
        cmds.warning(f'Unknown limb type: {limb}')
        return None

    positions = positions or default_positions
    names = [f'{side}_{part}_JNT' for part in part_names]

    joints = create_joint_chain(positions, names, radius=radius)

    return {
        'joints': joints,
        'root': joints[0],
        'mid': joints[1],
        'end': joints[2],
    }

# Create left and right arms
l_arm = create_limb_joints(side='L', limb='arm')
r_arm = create_limb_joints(side='R', limb='arm')
Tip

Always call cmds.select(clear=True) before creating joints. Maya automatically parents new joints under the currently selected joint, which can silently create incorrect hierarchies if something is selected.

IK/FK Setup

Most production rigs use an IK/FK blending system. The animator can seamlessly switch between inverse kinematics (goal-driven) and forward kinematics (rotation-driven) control. Here's how to set one up programmatically.

Python
import maya.cmds as cmds

def create_ikfk_setup(bind_joints, side='L', limb='arm'):
    """
    Create a full IK/FK system for a 3-joint limb.

    Args:
        bind_joints: Dict with 'root', 'mid', 'end' joint names (from create_limb_joints).
        side: 'L' or 'R'.
        limb: 'arm' or 'leg'.

    Returns:
        Dict containing IK joints, FK joints, IK handle, and switch attribute info.
    """
    prefix = f'{side}_{limb}'
    root_jnt = bind_joints['root']
    mid_jnt = bind_joints['mid']
    end_jnt = bind_joints['end']

    # --- Duplicate chains for IK and FK ---
    def duplicate_chain(suffix):
        """Duplicate bind chain and rename with a new suffix."""
        chain = cmds.duplicate(root_jnt, renameChildren=True)
        mapping = {}
        for orig, dup in zip([root_jnt, mid_jnt, end_jnt], chain[:3]):
            new_name = orig.replace('_JNT', f'_{suffix}_JNT')
            cmds.rename(dup, new_name)
            mapping[orig] = new_name
        return [mapping[j] for j in [root_jnt, mid_jnt, end_jnt]]

    ik_chain = duplicate_chain('IK')
    fk_chain = duplicate_chain('FK')

    # --- Create IK handle on the IK chain ---
    ik_handle, ik_effector = cmds.ikHandle(
        name=f'{prefix}_IKH',
        startJoint=ik_chain[0],
        endEffector=ik_chain[2],
        solver='ikRPsolver'
    )

    # Create pole vector locator
    mid_pos = cmds.xform(mid_jnt, query=True, translation=True, worldSpace=True)
    pole_offset = 30 if limb == 'arm' else 30
    pole_pos = [mid_pos[0], mid_pos[1], mid_pos[2] - pole_offset]

    pole_loc = cmds.spaceLocator(name=f'{prefix}_poleVector_LOC')[0]
    cmds.xform(pole_loc, translation=pole_pos, worldSpace=True)
    cmds.poleVectorConstraint(pole_loc, ik_handle)

    # --- Create IK/FK switch attribute ---
    switch_ctrl = f'{prefix}_settings_CTRL'
    if not cmds.objExists(switch_ctrl):
        switch_ctrl = cmds.spaceLocator(name=switch_ctrl)[0]

    if not cmds.attributeQuery('ikFkSwitch', node=switch_ctrl, exists=True):
        cmds.addAttr(switch_ctrl, longName='ikFkSwitch', attributeType='float',
                     minValue=0, maxValue=1, defaultValue=0, keyable=True)

    # --- Blend IK/FK into bind chain ---
    for bind_jnt, ik_jnt, fk_jnt in zip(
        [root_jnt, mid_jnt, end_jnt], ik_chain, fk_chain
    ):
        # Create blend nodes for rotation
        blend = cmds.createNode('blendColors', name=f'{bind_jnt}_ikfk_blend')

        cmds.connectAttr(f'{ik_jnt}.rotate', f'{blend}.color1')
        cmds.connectAttr(f'{fk_jnt}.rotate', f'{blend}.color2')
        cmds.connectAttr(f'{switch_ctrl}.ikFkSwitch', f'{blend}.blender')
        cmds.connectAttr(f'{blend}.output', f'{bind_jnt}.rotate', force=True)

    # --- Control visibility by IK/FK mode ---
    reverse_node = cmds.createNode('reverse', name=f'{prefix}_ikfk_reverse')
    cmds.connectAttr(f'{switch_ctrl}.ikFkSwitch', f'{reverse_node}.inputX')

    # IK visible when switch = 1, FK visible when switch = 0
    cmds.connectAttr(f'{switch_ctrl}.ikFkSwitch', f'{ik_handle}.visibility')
    cmds.connectAttr(f'{switch_ctrl}.ikFkSwitch', f'{pole_loc}.visibility')
    cmds.connectAttr(f'{reverse_node}.outputX', f'{fk_chain[0]}.visibility')

    print(f'IK/FK setup complete for {prefix}')
    print(f'  Switch attr: {switch_ctrl}.ikFkSwitch (0=FK, 1=IK)')

    return {
        'ik_chain': ik_chain,
        'fk_chain': fk_chain,
        'ik_handle': ik_handle,
        'pole_vector': pole_loc,
        'switch_ctrl': switch_ctrl,
    }
Warning

When duplicating joint chains, Maya appends numbers to the duplicated names. The duplicate_chain helper renames them immediately, but be careful with complex hierarchies - always verify your chain order after duplication.

Control Curve Creation and Coloring

Control curves are the animator's interface to the rig. Good controls are easy to select, clearly indicate their purpose, and follow a consistent color scheme (typically blue for left, red for right, yellow for center).

Python
import maya.cmds as cmds

# Color index mapping for override colors
SIDE_COLORS = {
    'L': 6,    # Blue
    'R': 13,   # Red
    'C': 17,   # Yellow
}

CONTROL_SHAPES = {
    'circle': lambda name, radius: cmds.circle(
        name=name, normal=(1, 0, 0), radius=radius, constructionHistory=False
    )[0],

    'square': lambda name, radius: cmds.curve(
        name=name, degree=1,
        point=[(-radius, 0, -radius), (-radius, 0, radius),
               (radius, 0, radius), (radius, 0, -radius),
               (-radius, 0, -radius)]
    ),

    'cube': lambda name, radius: cmds.curve(
        name=name, degree=1,
        point=[(-radius, radius, -radius), (-radius, radius, radius),
               (radius, radius, radius), (radius, radius, -radius),
               (-radius, radius, -radius), (-radius, -radius, -radius),
               (-radius, -radius, radius), (-radius, radius, radius),
               (-radius, -radius, radius), (radius, -radius, radius),
               (radius, radius, radius), (radius, -radius, radius),
               (radius, -radius, -radius), (radius, radius, -radius),
               (radius, -radius, -radius), (-radius, -radius, -radius)]
    ),

    'diamond': lambda name, radius: cmds.curve(
        name=name, degree=1,
        point=[(0, radius, 0), (radius, 0, 0), (0, 0, radius),
               (-radius, 0, 0), (0, 0, -radius), (radius, 0, 0),
               (0, -radius, 0), (-radius, 0, 0), (0, radius, 0),
               (0, 0, radius), (0, -radius, 0), (0, 0, -radius),
               (0, radius, 0)]
    ),

    'arrow': lambda name, radius: cmds.curve(
        name=name, degree=1,
        point=[(0, 0, -radius * 2), (radius, 0, 0), (radius * 0.4, 0, 0),
               (radius * 0.4, 0, radius * 2), (-radius * 0.4, 0, radius * 2),
               (-radius * 0.4, 0, 0), (-radius, 0, 0), (0, 0, -radius * 2)]
    ),
}

def create_control(name, shape='circle', radius=1.0, color=None, side=None):
    """
    Create a NURBS control curve with the specified shape and color.

    Args:
        name: Control name (should end with _CTRL).
        shape: Shape type from CONTROL_SHAPES dict.
        radius: Scale of the control shape.
        color: Override color index (0-31). Overrides side color.
        side: 'L', 'R', or 'C' - sets color automatically.

    Returns:
        Name of the created control transform.
    """
    if shape not in CONTROL_SHAPES:
        cmds.warning(f'Unknown shape "{shape}". Using circle.')
        shape = 'circle'

    ctrl = CONTROL_SHAPES[shape](name, radius)

    # Apply color
    color_index = color or SIDE_COLORS.get(side, 17)
    ctrl_shape = cmds.listRelatives(ctrl, shapes=True)[0]
    cmds.setAttr(f'{ctrl_shape}.overrideEnabled', 1)
    cmds.setAttr(f'{ctrl_shape}.overrideColor', color_index)

    return ctrl

def create_control_at(name, target, shape='circle', radius=1.0, side=None,
                      freeze=True):
    """
    Create a control at the position/orientation of a target node.

    Args:
        name: Control name.
        target: Node to match position/orientation to.
        shape: Control shape type.
        radius: Control size.
        side: 'L', 'R', or 'C' for coloring.
        freeze: If True, freeze transforms after positioning.

    Returns:
        Tuple of (control, offset_group).
    """
    ctrl = create_control(name, shape=shape, radius=radius, side=side)

    # Create offset group (zero group) to hold the control
    offset_grp = cmds.group(ctrl, name=name.replace('_CTRL', '_offset_GRP'))

    # Match transform to target
    cmds.matchTransform(offset_grp, target, position=True, rotation=True)

    if freeze:
        cmds.makeIdentity(ctrl, apply=True, translate=True, rotate=True, scale=True)

    return ctrl, offset_grp

# Example: Create arm controls
l_shoulder_ctrl, l_shoulder_grp = create_control_at(
    'L_shoulder_CTRL', 'L_shoulder_JNT', shape='circle', radius=5, side='L')

l_elbow_ctrl, l_elbow_grp = create_control_at(
    'L_elbow_CTRL', 'L_elbow_JNT', shape='circle', radius=4, side='L')

l_wrist_ctrl, l_wrist_grp = create_control_at(
    'L_wrist_CTRL', 'L_wrist_JNT', shape='cube', radius=3, side='L')
Tip

The "offset group" (also called a "zero group") is essential. It stores the world-space position of the control so the control itself can have zeroed-out transforms. When an animator resets the control to (0,0,0), it returns to its default pose - not the world origin.

Constraint-Based Setups

Constraints are how controls drive joints. The right constraint type depends on what the control needs to do - drive rotation only, follow a position, or blend between multiple targets.

Python
import maya.cmds as cmds

def constrain_fk_chain(controls, joints, maintain_offset=True):
    """
    Apply orient constraints from FK controls to their corresponding joints.

    Args:
        controls: List of FK control names.
        joints: List of joint names (same order as controls).
        maintain_offset: Whether to maintain offset on constraints.

    Returns:
        List of constraint names.
    """
    constraints = []
    for ctrl, jnt in zip(controls, joints):
        constraint = cmds.orientConstraint(
            ctrl, jnt, maintainOffset=maintain_offset,
            name=f'{jnt}_orientConstraint'
        )[0]
        constraints.append(constraint)
    return constraints

def create_space_switch(driven_node, spaces, switch_attr_node=None,
                        switch_attr_name='space', constraint_type='parent'):
    """
    Create a space switching system for a control.

    Args:
        driven_node: The node to be space-switched (usually the offset group).
        spaces: Dict of {space_name: driver_node}, e.g., {'world': 'world_CTRL', 'hip': 'C_hip_CTRL'}.
        switch_attr_node: Node to hold the enum attribute. Defaults to driven_node.
        switch_attr_name: Name of the enum attribute.
        constraint_type: 'parent', 'orient', or 'point'.

    Returns:
        Dict with constraint name and attribute info.
    """
    switch_node = switch_attr_node or driven_node
    space_names = list(spaces.keys())
    drivers = list(spaces.values())

    # Create the enum attribute
    enum_str = ':'.join(space_names)
    if not cmds.attributeQuery(switch_attr_name, node=switch_node, exists=True):
        cmds.addAttr(switch_node, longName=switch_attr_name,
                     attributeType='enum', enumName=enum_str, keyable=True)

    # Create the constraint with all drivers
    constraint_fn = {
        'parent': cmds.parentConstraint,
        'orient': cmds.orientConstraint,
        'point': cmds.pointConstraint,
    }[constraint_type]

    constraint = constraint_fn(
        *drivers, driven_node,
        maintainOffset=True,
        name=f'{driven_node}_spaceSwitch'
    )[0]

    # Get weight aliases from the constraint
    weight_attrs = constraint_fn(constraint, query=True, weightAliasList=True)

    # Create condition nodes to drive weights from the enum
    for i, (space_name, weight_attr) in enumerate(zip(space_names, weight_attrs)):
        condition = cmds.createNode('condition', name=f'{driven_node}_{space_name}_condition')
        cmds.setAttr(f'{condition}.secondTerm', i)
        cmds.setAttr(f'{condition}.colorIfTrueR', 1)
        cmds.setAttr(f'{condition}.colorIfFalseR', 0)
        cmds.setAttr(f'{condition}.operation', 0)  # Equal

        cmds.connectAttr(f'{switch_node}.{switch_attr_name}', f'{condition}.firstTerm')
        cmds.connectAttr(f'{condition}.outColorR', f'{constraint}.{weight_attr}')

    print(f'Space switch created on {driven_node}: {space_names}')
    return {'constraint': constraint, 'attr': f'{switch_node}.{switch_attr_name}'}

# Example: FK arm constraints
# constrain_fk_chain(
#     ['L_shoulder_CTRL', 'L_elbow_CTRL', 'L_wrist_CTRL'],
#     ['L_shoulder_FK_JNT', 'L_elbow_FK_JNT', 'L_wrist_FK_JNT']
# )

# Example: Space switch on the wrist
# create_space_switch(
#     'L_wrist_offset_GRP',
#     spaces={'world': 'world_CTRL', 'chest': 'C_chest_CTRL', 'hip': 'C_hip_CTRL'},
#     switch_attr_node='L_wrist_CTRL'
# )
Note

Space switching is one of the most requested features by animators. It lets them change what coordinate space a control operates in - for example, switching a hand from following the body to staying fixed in world space for a wall-push animation.

Building a Simple Auto-Rigger

Let's combine everything into a function that builds a basic biped upper body rig - spine, arms, and head - in a single call.

Python
import maya.cmds as cmds

def build_biped_rig():
    """
    Build a simple biped rig with spine, arms, neck, and head.
    Creates joints, controls, and constraints from scratch.
    """
    print('--- Building Biped Rig ---')

    # ==========================================
    # 1. Create rig hierarchy groups
    # ==========================================
    rig_grp = cmds.group(empty=True, name='rig_GRP')
    jnt_grp = cmds.group(empty=True, name='skeleton_GRP', parent=rig_grp)
    ctrl_grp = cmds.group(empty=True, name='controls_GRP', parent=rig_grp)
    geo_grp = cmds.group(empty=True, name='geometry_GRP', parent=rig_grp)

    # Lock geo group transforms to prevent accidental movement
    for attr in ['tx', 'ty', 'tz', 'rx', 'ry', 'rz', 'sx', 'sy', 'sz']:
        cmds.setAttr(f'{geo_grp}.{attr}', lock=True)

    # ==========================================
    # 2. Build spine joints
    # ==========================================
    spine_positions = [
        (0, 100, 0), (0, 108, -1), (0, 116, -1.5),
        (0, 124, -1), (0, 132, 0),
    ]
    spine_names = ['C_spine_base_JNT', 'C_spine_01_JNT', 'C_spine_02_JNT',
                   'C_spine_03_JNT', 'C_chest_JNT']
    spine_jnts = create_joint_chain(spine_positions, spine_names,
                                     radius=2.0, parent=jnt_grp)
    print(f'  Spine: {len(spine_jnts)} joints')

    # ==========================================
    # 3. Build neck and head
    # ==========================================
    neck_positions = [(0, 140, 0), (0, 148, 1), (0, 156, 0)]
    neck_names = ['C_neck_base_JNT', 'C_neck_01_JNT', 'C_head_JNT']
    neck_jnts = create_joint_chain(neck_positions, neck_names, radius=1.5)
    cmds.parent(neck_jnts[0], spine_jnts[-1])
    print(f'  Neck/Head: {len(neck_jnts)} joints')

    # ==========================================
    # 4. Build arms
    # ==========================================
    arm_data = {}
    for side in ['L', 'R']:
        mirror = 1 if side == 'L' else -1
        arm_positions = [
            (15 * mirror, 135, 0),   # shoulder
            (40 * mirror, 135, -5),  # elbow
            (65 * mirror, 135, 0),   # wrist
        ]
        arm_names = [f'{side}_shoulder_JNT', f'{side}_elbow_JNT', f'{side}_wrist_JNT']
        arm_jnts = create_joint_chain(arm_positions, arm_names, radius=1.5)
        cmds.parent(arm_jnts[0], spine_jnts[-1])
        arm_data[side] = {
            'joints': arm_jnts,
            'root': arm_jnts[0],
            'mid': arm_jnts[1],
            'end': arm_jnts[2],
        }
        print(f'  {side} Arm: {len(arm_jnts)} joints')

    # ==========================================
    # 5. Create controls
    # ==========================================

    # Global control
    global_ctrl = create_control('C_global_CTRL', shape='arrow', radius=12, side='C')
    cmds.parent(global_ctrl, ctrl_grp)
    cmds.parentConstraint(global_ctrl, jnt_grp, maintainOffset=True)

    # COG control
    cog_ctrl, cog_grp = create_control_at(
        'C_cog_CTRL', spine_jnts[0], shape='circle', radius=10, side='C')
    cmds.parent(cog_grp, global_ctrl)

    # Spine FK controls
    spine_ctrls = []
    spine_ctrl_grps = []
    for i, jnt in enumerate(spine_jnts):
        ctrl, grp = create_control_at(
            f'C_spine_{i:02d}_CTRL', jnt, shape='circle',
            radius=8 - i * 0.5, side='C')
        spine_ctrls.append(ctrl)
        spine_ctrl_grps.append(grp)

    # Parent spine controls in hierarchy
    cmds.parent(spine_ctrl_grps[0], cog_ctrl)
    for i in range(1, len(spine_ctrl_grps)):
        cmds.parent(spine_ctrl_grps[i], spine_ctrls[i - 1])

    # Constrain spine joints to controls
    for ctrl, jnt in zip(spine_ctrls, spine_jnts):
        cmds.orientConstraint(ctrl, jnt, maintainOffset=True)
    cmds.pointConstraint(spine_ctrls[0], spine_jnts[0], maintainOffset=True)

    # Head control
    head_ctrl, head_grp = create_control_at(
        'C_head_CTRL', neck_jnts[-1], shape='cube', radius=5, side='C')
    cmds.parent(head_grp, spine_ctrls[-1])
    cmds.orientConstraint(head_ctrl, neck_jnts[-1], maintainOffset=True)

    # Arm FK controls
    for side in ['L', 'R']:
        limb = arm_data[side]
        arm_ctrls = []
        arm_ctrl_grps = []
        for jnt_name in limb['joints']:
            part = jnt_name.split('_')[1]  # shoulder, elbow, wrist
            ctrl, grp = create_control_at(
                f'{side}_{part}_CTRL', jnt_name, shape='circle',
                radius=4, side=side)
            arm_ctrls.append(ctrl)
            arm_ctrl_grps.append(grp)

        # Parent arm control hierarchy
        cmds.parent(arm_ctrl_grps[0], spine_ctrls[-1])
        for i in range(1, len(arm_ctrl_grps)):
            cmds.parent(arm_ctrl_grps[i], arm_ctrls[i - 1])

        # Constrain arm joints
        for ctrl, jnt in zip(arm_ctrls, limb['joints']):
            cmds.orientConstraint(ctrl, jnt, maintainOffset=True)

    # ==========================================
    # 6. Lock and hide unused attributes
    # ==========================================
    all_ctrls = cmds.ls('*_CTRL')
    for ctrl in all_ctrls:
        for attr in ['sx', 'sy', 'sz', 'v']:
            cmds.setAttr(f'{ctrl}.{attr}', lock=True, keyable=False, channelBox=False)

    print('--- Biped Rig Complete ---')
    print(f'  Rig group: {rig_grp}')
    print(f'  Total joints: {len(cmds.ls(type="joint"))}')
    print(f'  Total controls: {len(all_ctrls)}')

    cmds.select(clear=True)
    return rig_grp

# Run the auto-rigger
# build_biped_rig()
Tip

This auto-rigger uses the create_joint_chain, create_control, and create_control_at functions defined earlier on this page. In a real production setup, these would live in a shared rig_utils module that all your rig scripts import from.

Organizing Rig Hierarchies

A well-organized rig is easier to debug, animate, and maintain. Here's how to structure your rig hierarchy programmatically and enforce naming conventions.

Python
import maya.cmds as cmds

RIG_HIERARCHY = {
    'char_GRP': {
        'skeleton_GRP': {},
        'controls_GRP': {
            'global_CTRL_GRP': {},
            'body_CTRL_GRP': {},
            'face_CTRL_GRP': {},
        },
        'geometry_GRP': {
            'render_GEO_GRP': {},
            'proxy_GEO_GRP': {},
        },
        'rig_systems_GRP': {
            'ik_systems_GRP': {},
            'constraints_GRP': {},
            'utility_nodes_GRP': {},
        },
        'DO_NOT_TOUCH_GRP': {},
    }
}

def create_hierarchy(structure, parent=None):
    """
    Recursively create a group hierarchy from a nested dict.

    Args:
        structure: Dict where keys are group names, values are child dicts.
        parent: Parent node name. None for world level.

    Returns:
        Dict mapping group names to their full DAG paths.
    """
    created = {}

    for group_name, children in structure.items():
        if cmds.objExists(group_name):
            grp = group_name
        else:
            grp = cmds.group(empty=True, name=group_name)
            if parent:
                cmds.parent(grp, parent)

        created[group_name] = grp

        if children:
            child_groups = create_hierarchy(children, parent=grp)
            created.update(child_groups)

    return created

def validate_rig_naming():
    """Check all rig nodes follow the naming convention: side_name_type."""
    valid_suffixes = ['GRP', 'CTRL', 'JNT', 'GEO', 'LOC', 'IKH', 'CRV', 'OFFSET']
    valid_sides = ['L', 'R', 'C']
    issues = []

    for node in cmds.ls(transforms=True):
        # Skip default cameras and UI elements
        if node in ['persp', 'top', 'front', 'side'] or '|' in node:
            continue

        parts = node.split('_')
        if len(parts) < 2:
            issues.append(f'  Missing suffix: {node}')
            continue

        suffix = parts[-1]
        if suffix not in valid_suffixes:
            issues.append(f'  Invalid suffix "{suffix}": {node}')

    if issues:
        print(f'Naming issues found ({len(issues)}):')
        for issue in issues[:20]:  # limit output
            print(issue)
    else:
        print('All naming conventions pass.')

    return issues

# Usage:
# groups = create_hierarchy(RIG_HIERARCHY)
# validate_rig_naming()
Warning

Always run your auto-rigger on a clean scene or with a delete-and-rebuild workflow. If you run it on top of an existing rig, you'll get duplicate nodes and broken connections. A common pattern is to have a delete_rig() function that removes everything under the top-level rig group before rebuilding.

Next Steps

This guide covers the fundamentals of scripted rigging. To take it further, explore adding stretch/squash systems, ribbon spines, twist extractors, and facial rigging. For the underlying Python techniques, see our PyMEL guide and Python API tutorial.