C.W.K.
Stream
Lesson 02 of 05 · published

What Breaks Purity: The Rogues Gallery

~10 min · purity, jax, tutorial

Level 0Curious
0 XP0/73 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete

Let's catalog every common way purity gets violated in JAX, so you can spot these patterns immediately.

1. In-Place Mutation

import jax.numpy as jnp

x = jnp.array([1, 2, 3])
# x[0] = 99  # TypeError: JAX arrays are immutable

# Fix: use .at[].set()
x_new = x.at[0].set(99)  # Returns new array, x is unchanged

2. Global / Closure Variables

import jax
import jax.numpy as jnp

learning_rate = 0.01

# BAD: reads from closure
@jax.jit
def update_bad(params, grads):
    return params - learning_rate * grads

# GOOD: pass as argument
@jax.jit
def update_good(params, grads, lr):
    return params - lr * grads

# OR: use static_argnums for values that rarely change
@jax.jit
def update_static(params, grads, lr):
    return params - lr * grads
# JAX will recompile when lr changes, but that's acceptable if it rarely does

3. Python print() Under JIT

import jax
import jax.numpy as jnp

@jax.jit
def fn_with_print(x):
    print("This runs during TRACING only, not execution!")
    y = x + 1
    print(f"y = {y}")  # Prints a tracer object, not a number
    return y

result = fn_with_print(jnp.array(5.0))
# Output during first call:
# "This runs during TRACING only, not execution!"
# "y = Traced<ShapedArray(float32[])>with<DynamicJaxprTrace...>"

# Second call: no print at all — JIT reuses the cached trace
result2 = fn_with_print(jnp.array(10.0))

For debugging, use jax.debug.print instead:

@jax.jit
def fn_with_debug_print(x):
    y = x + 1
    jax.debug.print("y = {}", y)  # Prints at execution time!
    return y

fn_with_debug_print(jnp.array(5.0))   # Prints "y = 6.0"
fn_with_debug_print(jnp.array(10.0))  # Prints "y = 11.0"

4. Random Number Generation

import jax
import jax.numpy as jnp

# NumPy uses global state — IMPURE
import numpy as np
np.random.seed(42)
a = np.random.randn(3)  # Mutates global RNG state
b = np.random.randn(3)  # Different result — depends on hidden state

# JAX uses explicit keys — PURE
key = jax.random.PRNGKey(42)
key1, key2 = jax.random.split(key)
a = jax.random.normal(key1, (3,))  # Deterministic given key1
b = jax.random.normal(key2, (3,))  # Deterministic given key2

# Same key always gives same result
a_again = jax.random.normal(key1, (3,))
print(jnp.allclose(a, a_again))  # True — pure!

💡 Why This Matters

JAX's explicit PRNG design is one of its most unique features. It means random computations are reproducible by default, parallelizable (each device can have its own key), and JIT-compatible. The trade-off is more verbose code — you must manually split keys and pass them around. But this explicitness eliminates an entire class of bugs where "my results changed because the global RNG state was different."

5. Python Control Flow That Depends on Values

import jax
import jax.numpy as jnp

# PROBLEMATIC under JIT: Python if depends on a traced value
@jax.jit
def bad_relu(x):
    if x > 0:  # ConcretizationTypeError!
        return x
    else:
        return 0.0

# GOOD: use JAX control flow
@jax.jit
def good_relu(x):
    return jnp.where(x > 0, x, 0.0)

We'll cover JIT control flow in detail in Track 4.

Code

import jax.numpy as jnp

x = jnp.array([1, 2, 3])
# x[0] = 99  # TypeError: JAX arrays are immutable

# Fix: use .at[].set()
x_new = x.at[0].set(99)  # Returns new array, x is unchanged
import jax
import jax.numpy as jnp

learning_rate = 0.01

# BAD: reads from closure
@jax.jit
def update_bad(params, grads):
    return params - learning_rate * grads

# GOOD: pass as argument
@jax.jit
def update_good(params, grads, lr):
    return params - lr * grads

# OR: use static_argnums for values that rarely change
@jax.jit
def update_static(params, grads, lr):
    return params - lr * grads
# JAX will recompile when lr changes, but that's acceptable if it rarely does
import jax
import jax.numpy as jnp

@jax.jit
def fn_with_print(x):
    print("This runs during TRACING only, not execution!")
    y = x + 1
    print(f"y = {y}")  # Prints a tracer object, not a number
    return y

result = fn_with_print(jnp.array(5.0))
# Output during first call:
# "This runs during TRACING only, not execution!"
# "y = Traced<ShapedArray(float32[])>with<DynamicJaxprTrace...>"

# Second call: no print at all — JIT reuses the cached trace
result2 = fn_with_print(jnp.array(10.0))
@jax.jit
def fn_with_debug_print(x):
    y = x + 1
    jax.debug.print("y = {}", y)  # Prints at execution time!
    return y

fn_with_debug_print(jnp.array(5.0))   # Prints "y = 6.0"
fn_with_debug_print(jnp.array(10.0))  # Prints "y = 11.0"
import jax
import jax.numpy as jnp

# NumPy uses global state — IMPURE
import numpy as np
np.random.seed(42)
a = np.random.randn(3)  # Mutates global RNG state
b = np.random.randn(3)  # Different result — depends on hidden state

# JAX uses explicit keys — PURE
key = jax.random.PRNGKey(42)
key1, key2 = jax.random.split(key)
a = jax.random.normal(key1, (3,))  # Deterministic given key1
b = jax.random.normal(key2, (3,))  # Deterministic given key2

# Same key always gives same result
a_again = jax.random.normal(key1, (3,))
print(jnp.allclose(a, a_again))  # True — pure!
import jax
import jax.numpy as jnp

# PROBLEMATIC under JIT: Python if depends on a traced value
@jax.jit
def bad_relu(x):
    if x > 0:  # ConcretizationTypeError!
        return x
    else:
        return 0.0

# GOOD: use JAX control flow
@jax.jit
def good_relu(x):
    return jnp.where(x > 0, x, 0.0)

External links

Exercise

Walk through your most recent Python script. Find every place that violates purity — global reads, list mutations, time/random side effects, print statements. List them in a table. Pick the top three to refactor first.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.