Share Data Between Processes in Python

Learn how to share data between Python processes using Value, Array, Manager, Pipe, and Queue with practical examples and performance comparisons.

8 min read

When you spawn a new Python process with the multiprocessing module, it starts with its own memory space. A variable you set in the parent process is invisible to the child. A list you modify in a worker process is invisible to the parent.

This isolation is by design. It is what lets each process run independently on its own CPU core without the GIL. But real programs need processes to share data, and Python gives you several tools for that, each with different performance characteristics and complexity tradeoffs.

If you have worked through the multiprocessing basics, you know how to create processes and pools. This article covers the mechanisms for making those processes share information.

The tools form a spectrum from low-level shared memory to high-level managed objects. Value and Array wrap raw shared memory blocks for C types. Manager creates a server process that proxies Python objects.

Queue and Pipe pass messages between processes using serialization. shared_memory lets you create named memory blocks that any process can attach to. Choosing the right tool depends on what data you need to share, how fast it must be, and how much synchronization you are willing to manage yourself.

Understanding process memory isolation

Before you choose a sharing mechanism, you need to understand why processes do not share data by default. When a child process starts with the spawn method (default on macOS and Windows), Python launches a fresh interpreter that imports your module. The child has its own copy of every global variable, initialized from scratch.

Changes in one copy do not affect the other.

pythonpython
import multiprocessing
 
shared_list = []
 
def worker():
    shared_list.append("modified by worker")
    print(f"Worker sees: {shared_list}")
 
if __name__ == "__main__":
    p = multiprocessing.Process(target=worker)
    p.start()
    p.join()
    print(f"Parent sees: {shared_list}")

The worker's own copy of the list gains an entry, but the parent's copy of shared_list never changes, since spawning the child gave it an independent copy of the module's global state rather than a reference to the parent's memory:

texttext
Worker sees: ['modified by worker']
Parent sees: []

The worker process appended to its own copy of shared_list. The parent's list stayed empty. This is the correct and expected behaviour for separate processes.

To share data, you must use one of the mechanisms described below.

Value and Array: fast shared memory for C types

multiprocessing.Value and multiprocessing.Array allocate shared memory blocks that multiple processes can read and write. They wrap C types, not Python objects, so they are fast but limited to integers, floats, and raw byte arrays.

pythonpython
import multiprocessing
 
def increment(shared_counter, lock):
    for _ in range(1000):
        with lock:
            shared_counter.value += 1
 
if __name__ == "__main__":
    counter = multiprocessing.Value("i", 0)
    lock = multiprocessing.Lock()
 
    processes = [
        multiprocessing.Process(target=increment, args=(counter, lock))
        for _ in range(4)
    ]
 
    for p in processes:
        p.start()
    for p in processes:
        p.join()
 
    print(f"Counter: {counter.value}")

All four processes increment the same shared memory location 1,000 times each, and the lock ensures none of those 4,000 increments are lost to a race, so the final count matches the expected total exactly:

texttext
Counter: 4000

The "i" specifies a signed integer. Other type codes include "d" (double), "f" (float), "c" (char), and "b" (signed char). Value stores a single value.

Array stores a sequence of the same type.

pythonpython
import multiprocessing
 
def fill_array(shared_arr, start):
    for i in range(5):
        shared_arr[start + i] = (start + i) * 10
 
if __name__ == "__main__":
    arr = multiprocessing.Array("i", 10)
 
    p1 = multiprocessing.Process(target=fill_array, args=(arr, 0))
    p2 = multiprocessing.Process(target=fill_array, args=(arr, 5))
 
    p1.start()
    p2.start()
    p1.join()
    p2.join()
 
    print(f"Array: {list(arr)}")

The two processes write to disjoint halves of the same shared array, and because their index ranges never overlap, the results merge cleanly into one sequence without any coordination between them:

texttext
Array: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]

Each process fills a different region of the array, so no lock is needed. When processes write to overlapping regions, or when one process reads while another writes, use a lock to prevent data corruption.

Value and Array accept an optional lock parameter. By default, they create a new RLock. Pass lock=False to disable locking and manage synchronization yourself.

Pass an existing Lock to share it.

Manager: sharing Python objects through a server process

multiprocessing.Manager creates a separate server process that holds Python objects and gives worker processes proxy objects that forward operations to the server through IPC. This lets you share lists, dictionaries, sets, and other Python objects as if they were local.

pythonpython
import multiprocessing
 
def worker(shared_dict, key, value):
    shared_dict[key] = value
 
if __name__ == "__main__":
    with multiprocessing.Manager() as manager:
        shared_dict = manager.dict()
 
        processes = [
            multiprocessing.Process(
                target=worker, args=(shared_dict, f"key-{i}", i * 10)
            )
            for i in range(4)
        ]
 
        for p in processes:
            p.start()
        for p in processes:
            p.join()
 
        print(f"Shared dict: {dict(shared_dict)}")

Each of the four worker processes writes its own key into the manager-backed dict, and because every write goes through the manager's server process rather than local memory, the parent sees all four entries once every worker has finished:

texttext
Shared dict: {'key-0': 0, 'key-1': 10, 'key-2': 20, 'key-3': 30}

The manager supports list, dict, Namespace, Lock, RLock, Semaphore, Condition, Event, Barrier, Queue, Value, and Array. Every operation on a managed object goes through the server process, so individual operations are atomic with respect to other processes using the same manager. However, compound operations like checking a value and then modifying it are not atomic and still need explicit locking.

pythonpython
# NOT atomic: another process could modify shared_list between these calls
if len(shared_list) > 0:
    item = shared_list.pop()
 
# Atomic alternative: use a managed lock
with managed_lock:
    if len(shared_list) > 0:
        item = shared_list.pop()

The convenience of managed objects comes at a performance cost. Every attribute access, method call, and item lookup on a managed object sends data to the manager server process and waits for a response. For high-throughput numeric data, Value and Array are much faster.

Queue and Pipe: message passing between processes

multiprocessing.Queue is a process-safe FIFO queue that uses serialization to pass Python objects between processes. It works like the queue.Queue covered in thread communication with queues, but uses pipes and locks internally to work safely across process boundaries instead of just threads.

pythonpython
import multiprocessing
 
def producer(q):
    for i in range(5):
        q.put(f"Item {i}")
 
def consumer(q):
    while True:
        item = q.get()
        if item is None:
            break
        print(f"Consumed: {item}")
 
if __name__ == "__main__":
    q = multiprocessing.Queue()
 
    p1 = multiprocessing.Process(target=producer, args=(q,))
    p2 = multiprocessing.Process(target=consumer, args=(q,))
 
    p1.start()
    p2.start()
    p1.join()
    q.put(None)
    p2.join()

multiprocessing.Pipe creates a pair of connection objects for bidirectional communication between two processes. It is faster than Queue for point-to-point communication but does not support multiple producers or consumers.

pythonpython
import multiprocessing
 
def worker(conn):
    conn.send("Hello from worker")
    msg = conn.recv()
    print(f"Worker received: {msg}")
    conn.close()
 
if __name__ == "__main__":
    parent_conn, child_conn = multiprocessing.Pipe()
 
    p = multiprocessing.Process(target=worker, args=(child_conn,))
    p.start()
 
    print(f"Parent received: {parent_conn.recv()}")
    parent_conn.send("Hello from parent")
 
    p.join()

The parent blocks on parent_conn.recv() until the worker sends its greeting, and the worker blocks on conn.recv() until the parent sends its own message back, so both sides end up printing what the other side sent:

texttext
Parent received: Hello from worker
Worker received: Hello from parent

Pipe() returns two connection objects, one for each endpoint. Both endpoints can send and receive. By default, the pipe is duplex (bidirectional).

Pass duplex=False to create a one-directional pipe where one end only sends and the other only receives.

Pipes are faster than queues for point-to-point communication because they avoid the locking and signaling overhead of a full queue implementation. Use a pipe when exactly two processes communicate. Use a queue when you need multiple producers, multiple consumers, or the task_done() and join() coordination pattern.

shared_memory: zero-copy buffers for large data

Python 3.8 introduced multiprocessing.shared_memory, which lets processes share raw memory buffers without serialization. This is useful for large binary data like NumPy arrays, image buffers, or serialized protocol buffers.

pythonpython
import multiprocessing
from multiprocessing import shared_memory
import numpy as np
 
def worker(shm_name, shape, dtype):
    existing_shm = shared_memory.SharedMemory(name=shm_name)
    arr = np.ndarray(shape, dtype=dtype, buffer=existing_shm.buf)
    arr[0] = 99
    existing_shm.close()

The worker attaches to the shared block by name rather than receiving the array itself, which is what avoids copying or pickling the data when the child process starts:

pythonpython
if __name__ == "__main__":
    arr = np.zeros(10, dtype=np.int32)
    shm = shared_memory.SharedMemory(create=True, size=arr.nbytes)
 
    shared_arr = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shm.buf)
    shared_arr[:] = arr[:]
 
    p = multiprocessing.Process(
        target=worker, args=(shm.name, arr.shape, arr.dtype)
    )
    p.start()
    p.join()
 
    print(f"Modified: {shared_arr}")
    shm.close()
    shm.unlink()

The worker process attaches to the shared memory by name, creates a NumPy array backed by the shared buffer, and modifies it. The parent sees the modification because both processes access the same physical memory. No data is copied or serialized.

shared_memory is the fastest way to share large data between processes. The tradeoff is that you manage the memory lifecycle manually: you must close() and unlink() the shared memory block when done, and you must coordinate access to avoid race conditions.

Comparing the approaches

MechanismSpeedData TypesComplexityUse Case
Value / ArrayFastestC types onlyLowSimple counters, flags, small arrays
ManagerSlowestAny Python objectLowestConvenience over performance
QueueMediumAny picklable objectLowGeneral message passing
PipeFastAny picklable objectMediumTwo-process communication
shared_memoryFastest for large dataRaw bytesHighNumPy arrays, large buffers

The performance differences are significant. Accessing a Value takes nanoseconds. Accessing a managed list element takes microseconds because every operation involves serialization, IPC, and a round trip to the manager server.

For programs that exchange data frequently, choosing the wrong mechanism can dominate runtime.

The simplest correct approach is often the best starting point. Use Queue for message passing, Value for numeric counters, and Manager only when you need to share a complex Python object and the performance cost is acceptable for your workload. Move to shared_memory only when profiling shows that serialization is your bottleneck.

Rune AI

Rune AI

Key Insights

  • Processes do not share memory by default; all inter-process data sharing requires explicit mechanisms.
  • Value and Array provide fast shared memory for C types like int and float; use a lock to prevent race conditions.
  • Manager creates a server process that proxies access to shared Python objects like lists and dicts.
  • Queue is the safest general-purpose channel for passing messages between processes.
  • Pipe provides faster point-to-point communication between exactly two processes.
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between multiprocessing.Value and multiprocessing.Manager?

Value and Array use shared memory (mmap) directly, giving the fastest access but only supporting C types like integers and floats. Manager creates a separate server process that holds Python objects and proxies access from worker processes. Manager supports arbitrary Python objects like lists and dicts, but every access goes through IPC to the server process, making it slower than Value and Array. Use Value and Array for simple numeric data that needs maximum performance. Use Manager for complex data structures where convenience matters more than speed.

How do I pass a large NumPy array between Python processes?

Use multiprocessing.shared_memory (Python 3.8+) to create a shared memory block and have each process access it directly without copying. NumPy arrays can be backed by shared memory using np.ndarray with the shared memory buffer. This is much faster than pickling the array through a Queue or Pipe for large data. Alternatively, use multiprocessing.RawArray with a ctypes type, or the Array class with a lock if synchronization is needed.

When should I use Pipe vs Queue for inter-process communication?

Use Pipe when you need a simple two-endpoint connection between exactly two processes. Pipe is faster than Queue for point-to-point communication because it avoids the internal locking and signaling overhead of a full queue. Use Queue when you need multiple producers, multiple consumers, or the convenience of task_done() and join(). Queue is the safer default for most use cases.

Conclusion

Sharing data between processes is the hardest part of multiprocessing, and Python gives you a clear hierarchy of tools. Start with Queue or Pipe for message passing when data is small and processes are independent. Move to Value and Array when you need fast shared numeric state. Reach for Manager when you need shared Python objects and are willing to pay the IPC cost. Use shared_memory for large binary buffers like NumPy arrays. The right choice depends on your data size, access pattern, and how much code complexity you are willing to accept for performance.