Core concept

The mujoco Python package is a thin, fast binding to MuJoCo’s C library. mujoco.MjModel and mujoco.MjData wrap the C structs, every array field is exposed as a NumPy array that points into MuJoCo’s own memory, and every C function mj_* and mju_* is available under the same name. There is no Python physics: mujoco.mj_step(model, data) calls the C function directly.

Three habits make Python code against MuJoCo correct and fast:

  1. Use named access (data.joint("hinge").qpos) instead of computing indices by hand.
  2. Copy what you keep. Arrays you read from data are views. They change when the simulation steps.
  3. Batch what you repeat. A Python loop costs microseconds per step on top of the physics; mujoco.rollout steps many trajectories in C, across threads.

Visual intuition

The lab runs the cart-pole falling from a tilted start. Its two plots are produced by the same code path the Python logging bug follows: one stores a reference to the tip position, the other stores a copy each frame. In JavaScript the bug looks identical, because the browser bindings also return live views (data.site_xpos is a Float64Array over WebAssembly memory).

```lab simlab {“dock”: true, “title”: “A view is not a snapshot”, “model”: “cartpole”, “key”: 0, “height”: 240, “camera”: {“azimuth”: -90, “elevation”: 8, “distance”: 2.6, “target”: [0, 0, 0.6]}, “setup”: “ctx.view = data.site_xpos; ctx.copies = []”, “trail”: “pole_tip”, “readouts”: [ {“label”: “tip x, live (m)”, “expr”: “data.site_xpos[0]”, “digits”: 4}, {“label”: “tip x from the stored view (m)”, “expr”: “ctx.view[0]”, “digits”: 4}], “plots”: [{“ylabel”: “tip x (m)”, “window”: 4, “traces”: [ {“label”: “read now (m)”, “expr”: “data.site_xpos[0]”}, {“label”: “stored view (m)”, “expr”: “ctx.view[0]”, “dash”: true}]}], “note”: “Both traces coincide: the stored view always shows the current value, never the value at the moment it was stored. That is exactly what goes wrong when a log appends views. (In JavaScript there is a second hazard, covered in Lesson 3.4: a stored view goes dead if the WebAssembly heap grows.)”}


## Named access

> [!established] Named access in the Python bindings
> `model.body(name_or_id)`, `model.joint(...)`, `model.geom(...)`, `model.site(...)`, `model.actuator(...)`, `model.sensor(...)`, `model.camera(...)` and the corresponding `data.*(...)` return small accessor objects whose attributes are the rows of the underlying arrays, without the type prefix: `model.body("pole").mass` is `model.body_mass[id]`, `data.joint("hinge").qpos` is the slice of `qpos` belonging to that joint, `data.sensor("pole_angle").data` is that sensor's slice of `sensordata`. `.id` and `.name` are always available. A wrong name raises a `KeyError` that lists the valid names.

Named access is not just convenience. Indices into `qpos` depend on every joint declared before the one you want, so adding an object to a scene silently shifts them. Names do not move.

## Views and copies

> [!warning] The commonest silent bug in MuJoCo Python code
> `data.qpos`, `data.xpos`, `data.body("pole").xpos` and their kin are NumPy views of `mjData` memory. Appending one to a list appends a reference, so after the loop every entry shows the final value. Call `.copy()` on anything you store. Writing through a view is fine and intended: `data.qpos[2] = 0.5` writes into MuJoCo's state.

Assigning a whole array is also safe: `data.qpos = new_values` copies the values into MuJoCo's buffer (it does not rebind the attribute to your array), so previously obtained views see the new values. This differs from what NumPy users might expect from attribute assignment, in a helpful direction.

## Implementation

```io
INPUT: `cartpole.xml` and `cube_table.xml`
PROCESS: log a moving site two ways; read values by name and by index; list contacts with forces; time a Python loop against `mujoco.rollout`
OUTPUT: printed comparisons

```python file=examples/l3_1_python_bindings.py “"”Lesson 3.1: the Python bindings, the views-versus-copies trap, and batch rollouts.

INPUT cartpole.xml and cube_table.xml PROCESS (1) log a moving body’s position the wrong way and the right way; (2) read the same quantities by name and by index; (3) list contacts with the geoms’ names and the contact forces; (4) time a Python stepping loop against mujoco.rollout on 64 trajectories OUTPUT printed comparisons

Run: python examples/l3_1_python_bindings.py “””

import os import time

import mujoco import mujoco.rollout import numpy as np

from mjcourse import model_path

FAST = os.environ.get(“MJC_FAST”) == “1” # set by the test suite to keep runs short

def logging_trap() -> None: model = mujoco.MjModel.from_xml_path(str(model_path(“cartpole”))) data = mujoco.MjData(model) mujoco.mj_resetDataKeyframe(model, data, 0) # “tilted”: the pole falls wrong, right = [], [] for _ in range(200): mujoco.mj_step(model, data) tip = data.site(“pole_tip”).xpos # a view into mjData memory wrong.append(tip) # appends the same view 200 times right.append(tip.copy()) # appends a snapshot wrong, right = np.array(wrong), np.array(right) print(f” logged views: x range over 0.4 s = {np.ptp(wrong[:, 0]):.4f} m (every row is the last value)”) print(f” logged copies: x range over 0.4 s = {np.ptp(right[:, 0]):.4f} m”)

def names_and_indices() -> None: model = mujoco.MjModel.from_xml_path(str(model_path(“cartpole”))) data = mujoco.MjData(model) data.qpos[:] = [0.2, 0.1] mujoco.mj_forward(model, data) pole = model.body(“pole”).id print(f” model.body(‘pole’).mass = {model.body(‘pole’).mass[0]:.3f} kg (model.body_mass[{pole}] = {model.body_mass[pole]:.3f})”) print(f” data.joint(‘hinge’).qpos = {data.joint(‘hinge’).qpos} (data.qpos[model.jnt_qposadr[1]] = {data.qpos[model.jnt_qposadr[1]]:.3f})”) print(f” data.sensor(‘pole_angle’).data = {data.sensor(‘pole_angle’).data}”) print(f” data.xmat[{pole}] has shape {data.xmat[pole].shape}; reshape(3, 3) for the rotation matrix”)

def contacts() -> None: model = mujoco.MjModel.from_xml_path(str(model_path(“cube_table”))) data = mujoco.MjData(model) for _ in range(300): mujoco.mj_step(model, data) force = np.zeros(6) for i, c in enumerate(data.contact[: data.ncon]): mujoco.mj_contactForce(model, data, i, force) print(f” contact {i}: {model.geom(c.geom1).name} / {model.geom(c.geom2).name}, “ f”dist {1e3 * c.dist:+.3f} mm, normal force {force[0]:.3f} N”)

def batch_rollouts() -> None: model = mujoco.MjModel.from_xml_path(str(model_path(“cartpole”))) data = mujoco.MjData(model) nbatch, nstep = 64, 100 if FAST else 500 spec = mujoco.mjtState.mjSTATE_FULLPHYSICS state0 = np.zeros((nbatch, mujoco.mj_stateSize(model, spec))) rng = np.random.default_rng(0) for k in range(nbatch): # 64 random initial pole angles data.qpos[:] = [0.0, rng.uniform(-0.3, 0.3)] data.qvel[:] = 0 data.time = 0 mujoco.mj_getState(model, data, state0[k], spec) controls = np.zeros((nbatch, nstep, model.nu))

t = time.perf_counter()
for k in range(nbatch):                               # plain Python loop
    mujoco.mj_setState(model, data, state0[k], spec)
    for _ in range(nstep):
        mujoco.mj_step(model, data)
loop = time.perf_counter() - t

datas = [mujoco.MjData(model) for _ in range(4)]      # one MjData per worker thread
t = time.perf_counter()
states, _ = mujoco.rollout.rollout(model, datas, state0, controls)
batched = time.perf_counter() - t
print(f"  {nbatch} x {nstep} steps: Python loop {1e3 * loop:.1f} ms, mujoco.rollout (4 threads) {1e3 * batched:.1f} ms")
print(f"  final states agree: {np.allclose(states[-1, -1, 1:3], data.qpos)}  (state layout: time, qpos, qvel)")

if name == “main”: print(“logging a moving site:”) logging_trap() print(“named access and indices:”) names_and_indices() print(“contacts after the dropped cube lands:”) contacts() print(“stepping many trajectories:”) batch_rollouts()

Output on this course's build machine (the timings are machine-dependent; everything else is not):

```text
logging a moving site:
  logged views:  x range over 0.4 s = 0.0000 m (every row is the last value)
  logged copies: x range over 0.4 s = 0.2269 m
named access and indices:
  model.body('pole').mass   = 0.200 kg  (model.body_mass[2] = 0.200)
  data.joint('hinge').qpos  = [0.1]  (data.qpos[model.jnt_qposadr[1]] = 0.100)
  data.sensor('pole_angle').data = [0.1]
  data.xmat[2] has shape (9,); reshape(3, 3) for the rotation matrix
contacts after the dropped cube lands:
  contact 0: floor / drop_cube, dist -0.108 mm, normal force 1.226 N
  ...
  contact 7: floor / rest_cube, dist -0.108 mm, normal force 1.226 N
stepping many trajectories:
  64 x 500 steps: Python loop 85.5 ms, mujoco.rollout (4 threads) 23.3 ms
  final states agree: True  (state layout: time, qpos, qvel)

Two details of the contact listing preview Level 9: a resting box makes four contacts with the floor, one per bottom corner, and each carries a quarter of the box’s weight ($0.5 \times 9.81 / 4 = 1.226$ N). And data.contact holds only data.ncon valid entries; iterate over data.contact[: data.ncon].

[!established] Row-major 3 by 3 matrices MuJoCo stores rotation matrices (xmat, geom_xmat, site_xmat, cam_xmat) as 9 numbers in row-major order. data.xmat[b].reshape(3, 3) gives the matrix whose columns are the body’s $x$, $y$, $z$ axes in world coordinates. All of MuJoCo’s matrices are row-major, as the documentation’s Data layout section states.

[!implementation] What mujoco.rollout does mujoco.rollout.rollout(model, datas, initial_state, control) steps nbatch trajectories of nstep steps from given initial states with open-loop controls, distributing them over one thread per MjData you pass, and returns the states (and sensor data) at every step. The state layout follows the mjtState specification (here mjSTATE_FULLPHYSICS: time, then qpos, then qvel). It is the right tool for sampling-based planning and for evaluating many initial states; it cannot run a feedback policy inside the loop, because the controls are fixed in advance.

Debugging

Every logged value is identical. Views, as above. Add .copy().

data.contact[i] beyond ncon contains plausible-looking numbers. The contact array is preallocated; entries after ncon are stale. Slice to ncon.

TypeError about array shapes when calling an mj_* function. The C function writes into the array you pass, so it must have the exact size and be contiguous and writeable: np.zeros(6) for mj_contactForce, np.zeros((3, model.nv)) for mj_jacSite. A slice of a larger array is fine only if it is contiguous.

A Python simulation loop is slow. For the cart-pole, one mj_step costs about 2.7 µs on this machine; for the 30-DOF pick-and-place scene, about 35 µs. If the loop body does Python work (dictionary lookups, NumPy allocations, named access in the inner loop), that work can dominate. Look up ids once outside the loop, preallocate arrays, and move batch work into mujoco.rollout.

Exercise

Write record(model, data, nstep, fields) -> dict[str, np.ndarray] that steps a model and returns, for each requested mjData field name (for example "qpos", "site_xpos", "sensordata"), an array of shape (nstep, *field.shape). Write a test that fails if any field is accidentally recorded as views.

Challenge

Use mujoco.rollout to estimate, for the cart-pole with zero control, the probability that the pole falls past 0.5 rad within 1 s, as a function of the initial angle, from 1000 samples per angle. Then make the same estimate with a Python loop and report the wall-time ratio at 1, 4 and 16 threads.

Research connection

Most sampling-based planners (MPPI, CEM) and many evaluation pipelines are rollout-bound. Measuring where time goes (physics, Python overhead, rendering) before optimizing is the first step of any performance claim; Level 20.3 does this properly and compares CPU threading with GPU simulation through MJX and MuJoCo Warp.

{"id": "3.1-check", "title": "Knowledge check", "questions": [
  {"kind": "mcq", "q": "What does <code>log.append(data.qpos)</code> inside a 100-step loop produce?",
   "options": ["100 snapshots of the trajectory", "100 references to the same array, all showing the final state", "A copy of the first state repeated", "An error"],
   "answer": 1,
   "explain": "<p><code>data.qpos</code> is a view. Use <code>data.qpos.copy()</code>.</p>"},
  {"kind": "mcq", "q": "A scene gains one extra free object declared before the robot. Which code keeps working?",
   "options": ["<code>data.qpos[7:14]</code> for the robot's joints", "<code>data.joint('j1').qpos</code>", "<code>data.qvel[6]</code> for the first robot joint", "None of them"],
   "answer": 1,
   "explain": "<p>Indices shift by 7 in <code>qpos</code> and 6 in <code>qvel</code> per free joint declared earlier; names do not move.</p>"},
  {"kind": "numeric", "q": "A resting box on a plane shows four contacts. The box has mass 2 kg. What normal force (N) does each contact carry, with g = 9.81 m/s²?",
   "answer": 4.905, "tol": 0.01, "unit": "N",
   "explain": "<p>$2 \\times 9.81 / 4$ = 4.905 N, by symmetry, for a box resting flat and centred.</p>"}
]}

Next

Lesson 3.2 makes pictures: the interactive viewer for debugging, and mujoco.Renderer for images you can compute with.