Context affinity allows you to bind an Erlang process to a dedicated Python context, preserving Python state (variables, imports, objects) across multiple py:call/eval/exec invocations.
By default, each call to py:call, py:eval, or py:exec may be handled by a different context from the pool. This means:
- Variables defined in one call are not available in the next
- Imported modules must be re-imported
- Objects created in one call cannot be accessed later
Context affinity solves this by dedicating a context to your process, ensuring all calls go to the same Python interpreter with preserved state.
The simplest approach is to use explicit context handles:
%% Get a specific context by index (1-based)
Ctx = py:context(1),
%% Now all calls to this context share state
ok = py:exec(Ctx, <<"counter = 0">>),
ok = py:exec(Ctx, <<"counter += 1">>),
{ok, 1} = py:eval(Ctx, <<"counter">>),
ok = py:exec(Ctx, <<"counter += 1">>),
{ok, 2} = py:eval(Ctx, <<"counter">>).Use multiple contexts for isolation:
%% Get two different contexts
Ctx1 = py:context(1),
Ctx2 = py:context(2),
%% Each context has its own namespace
ok = py:exec(Ctx1, <<"x = 'context one'">>),
ok = py:exec(Ctx2, <<"x = 'context two'">>),
%% Values are isolated
{ok, <<"context one">>} = py:eval(Ctx1, <<"x">>),
{ok, <<"context two">>} = py:eval(Ctx2, <<"x">>).For automatic context routing, bind a context to the current process:
%% Get a context
Ctx = py_context_router:get_context(),
%% Bind it to the current process
ok = py_context_router:bind_context(Ctx),
%% Now all py:call/eval/exec from this process use the bound context
ok = py:exec(<<"x = 42">>),
{ok, 42} = py:eval(<<"x">>),
%% Unbind when done
ok = py_context_router:unbind_context().Always ensure cleanup with try/after:
run_with_context(Fun) ->
Ctx = py_context_router:get_context(),
ok = py_context_router:bind_context(Ctx),
try
Fun()
after
py_context_router:unbind_context()
end.
%% Usage
Result = run_with_context(fun() ->
ok = py:exec(<<"total = 0">>),
ok = py:exec(<<"for i in range(10): total += i">>),
py:eval(<<"total">>)
end),
{ok, 45} = Result.Get the context for the current scheduler (automatic affinity):
Ctx = py:context().Get a specific context by index (1-based):
Ctx = py:context(1).Bind a context to the current process:
ok = py_context_router:bind_context(Ctx).Remove the context binding for the current process:
ok = py_context_router:unbind_context().Get a context from the pool:
%% Get context for current scheduler
Ctx = py_context_router:get_context().
%% Get context from a specific pool
Ctx = py_context_router:get_context(io).Ctx = py:context(1),
%% Load a model once
py:exec(Ctx, <<"
import pickle
with open('model.pkl', 'rb') as f:
model = pickle.load(f)
">>),
%% Use it multiple times
{ok, Pred1} = py:eval(Ctx, <<"model.predict([[1, 2, 3]])">>),
{ok, Pred2} = py:eval(Ctx, <<"model.predict([[4, 5, 6]])">>).Ctx = py:context(1),
%% Establish connection once
py:exec(Ctx, <<"
import sqlite3
conn = sqlite3.connect(':memory:')
cursor = conn.cursor()
cursor.execute('CREATE TABLE users (id INTEGER, name TEXT)')
">>),
%% Use the connection across multiple calls
py:exec(Ctx, <<"cursor.execute('INSERT INTO users VALUES (1, \"Alice\")')">>),
py:exec(Ctx, <<"cursor.execute('INSERT INTO users VALUES (2, \"Bob\")')">>),
{ok, Users} = py:eval(Ctx, <<"cursor.execute('SELECT * FROM users').fetchall()">>),
%% Clean up
py:exec(Ctx, <<"conn.close()">>).Ctx = py:context(1),
%% Initialize accumulator
py:exec(Ctx, <<"results = []">>),
%% Process items one at a time
lists:foreach(fun(Item) ->
py:eval(Ctx, <<"results.append(item * 2)">>, #{item => Item})
end, [1, 2, 3, 4, 5]),
%% Get final results
{ok, [2, 4, 6, 8, 10]} = py:eval(Ctx, <<"results">>).By default, without explicit binding, calls are routed based on the current Erlang scheduler. This provides good cache locality while allowing multiple processes to share contexts:
%% Processes on the same scheduler share a context
%% Processes on different schedulers use different contexts
{ok, Result} = py:call(math, sqrt, [16]).This is usually what you want for stateless operations where isolation isn't critical.
- Context binding overhead:
bind_context()requires a gen_server call - Lookup overhead: Once bound, routing adds only an O(1) ETS lookup
- Pool exhaustion: Each bound context removes it from round-robin rotation
- Recommendation: Use explicit
py:context(N)for stateful operations; let automatic routing handle stateless calls
Check pool status:
%% Check number of contexts
N = py_context_router:num_contexts().
%% Check if a pool is started
true = py_context_router:pool_started(default).
true = py_context_router:pool_started(io).
%% Get all contexts in a pool
Contexts = py_context_router:contexts(default).%% If contexts aren't started
case py:contexts_started() of
true -> proceed();
false -> {error, contexts_not_started}
end.Note: For a detailed guide on building "Python actors" and the Erlang philosophy behind process-bound environments, see Process-Bound Environments.
Process-bound environments provide true process-level isolation for Python state. Each Erlang process automatically gets its own Python namespace that persists across calls.
When you call py:call(), py:eval(), or py:exec(), the library automatically:
- Looks up or creates a process-local Python environment for your Erlang process
- Executes the Python code using that environment
- Stores variables, imports, and objects in that environment
- Cleans up automatically when your Erlang process exits
This happens transparently - no explicit binding required.
%% Get a context
Ctx = py:context(1),
%% Define a variable - it persists for THIS Erlang process
ok = py:exec(Ctx, <<"counter = 0">>),
ok = py:exec(Ctx, <<"counter += 1">>),
{ok, 1} = py:eval(Ctx, <<"counter">>).
%% In a different Erlang process, counter is independent:
spawn(fun() ->
ok = py:exec(Ctx, <<"counter = 100">>),
{ok, 100} = py:eval(Ctx, <<"counter">>)
end).
%% Back in original process, still 1
{ok, 1} = py:eval(Ctx, <<"counter">>).Process-bound environments are ideal for scenarios where each Erlang process needs isolated Python state:
%% Each user session gets its own chat history
handle_user_session(UserId) ->
Ctx = py:context(),
%% Initialize conversation for this process
ok = py:exec(Ctx, <<"
conversation_history = []
def add_message(role, content):
conversation_history.append({'role': role, 'content': content})
def get_history():
return conversation_history
">>),
session_loop(Ctx).
session_loop(Ctx) ->
receive
{user_message, Msg} ->
py:call(Ctx, '__main__', add_message, [<<"user">>, Msg]),
%% Process with AI...
session_loop(Ctx);
get_history ->
{ok, History} = py:call(Ctx, '__main__', get_history, []),
History
end.%% Process A
spawn(fun() ->
Ctx = py:context(1),
ok = py:exec(Ctx, <<"x = 'from process A'">>)
end),
%% Process B - same context, but isolated environment
spawn(fun() ->
Ctx = py:context(1), %% Same context!
ok = py:exec(Ctx, <<"x = 'from process B'">>),
{ok, <<"from process B">>} = py:eval(Ctx, <<"x">>) %% Own value
end).Environments are automatically freed when:
- The Erlang process exits (normal, abnormal, or killed)
- The NIF resource destructor runs during garbage collection
No manual cleanup is needed. The environments use the correct memory allocator for each interpreter (critical for subinterpreters which have isolated allocators).
Good use cases:
- Stateful sessions (chat, game state, user preferences)
- Long-running workers that accumulate state
- Process-per-request patterns with state
- AI pipelines with per-request context
Consider alternatives when:
- State must be shared between Erlang processes (use shared state API instead)
- State needs to outlive the Erlang process (use explicit storage)
- You need multiple independent namespaces per process (use explicit contexts)
Process-bound environments work by:
- Storing a
reference()in the calling process's dictionary underpy_local_env - The reference points to a Python dict created inside the interpreter
- Each interpreter ID maps to a separate environment (for subinterpreter support)
- The NIF uses this dict as
localsforexec()andeval()operations
For subinterpreters, environments are created inside the target interpreter to ensure memory safety - Python's subinterpreters have isolated memory allocators.
- Use explicit contexts for stateful operations:
Ctx = py:context(1)ensures state persists - Use automatic routing for stateless calls: Let the router handle distribution
- Always unbind in finally blocks: Prevent context leaks
- Minimize binding time: Don't hold contexts longer than necessary
- Monitor pool size: Check
py_context_router:num_contexts()to understand capacity - Leverage process-bound environments: For per-process state, rely on automatic environment isolation rather than manual binding