Context Affinity
View SourceContext 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.
Why Context Affinity?
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.
Using Explicit Contexts
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">>).Multiple Independent Contexts
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">>).Binding Contexts to Processes
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().Scoped Binding with try/after
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.Context API Reference
py:context/0
Get the context for the current scheduler (automatic affinity):
Ctx = py:context().py:context/1
Get a specific context by index (1-based):
Ctx = py:context(1).py_context_router:bind_context/1
Bind a context to the current process:
ok = py_context_router:bind_context(Ctx).py_context_router:unbind_context/0
Remove the context binding for the current process:
ok = py_context_router:unbind_context().py_context_router:get_context/0,1
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).Use Cases
Stateful Computation
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]])">>).Database Connections
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()">>).Incremental Processing
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">>).Scheduler Affinity (Default Behavior)
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.
Performance Considerations
- 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
Context Pool Statistics
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).Error Handling
Context Not Available
%% If contexts aren't started
case py:contexts_started() of
true -> proceed();
false -> {error, contexts_not_started}
end.Process-Bound Environments
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.
How It Works
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.
Basic Usage
%% 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 Affinity for AI Workloads
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.Isolation Between Processes
%% 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).Memory Management
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).
When to Use Process-Bound Environments
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)
Technical Details
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.
Best Practices
- 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