Python Thread Communication with Queues

Learn how to use queue.Queue for safe thread communication in Python, including producer-consumer patterns, task coordination, and priority queues.

7 min read

Sharing mutable state between threads with locks works, but it is fragile. Every shared variable, every list append, every dictionary update becomes a place where you can forget a lock and introduce a race condition that appears once every thousand runs. Python's queue.Queue offers a simpler model: instead of threads touching the same data, they communicate by passing messages through a thread-safe channel.

One thread puts items in, another takes them out, and the queue handles all the synchronization. If you have used locks to synchronize threads, you know how much discipline that requires. Queues replace that discipline with a well-tested abstraction that is harder to use incorrectly.

The queue pattern is not unique to Python. It is one of the fundamental building blocks of concurrent programming across languages, often called the producer-consumer pattern. Producers generate work and put it into a queue.

Consumers take work from the queue and process it. Neither side knows or cares how many of the other side exist. You can add more producers when work arrives faster, add more consumers when processing falls behind, and the queue absorbs the difference.

This decoupling is what makes queue-based designs more maintainable than lock-based designs as programs grow.

The three queue classes

The queue module provides three queue implementations, each suited to a different access pattern:

  • Queue (FIFO): First in, first out. Items are retrieved in the order they were added. This is the default and the right choice for most producer-consumer workloads where fairness matters.

  • LifoQueue (LIFO): Last in, first out. Items are retrieved in reverse order of insertion, like a stack. Use this when the most recently added items should be processed first, such as depth-first work queues.

  • PriorityQueue: Items are retrieved in priority order. Each item is a tuple of (priority, data), and lower priority numbers come out first. Use this when some work items are more urgent than others.

All three share the same interface: put(), get(), task_done(), join(), and qsize(). You can swap between them by changing only the constructor call if your access pattern changes.

pythonpython
import queue
import threading
import time
 
def consumer(q, name):
    while True:
        item = q.get()
        if item is None:
            break
        print(f"{name} processing {item}")
        time.sleep(0.1)
        q.task_done()

Two consumer threads are started before any work exists, so they immediately block inside q.get() waiting for items. The main thread then puts four requests on the queue and calls q.join() to wait for all of them to be marked done:

pythonpython
q = queue.Queue()
 
threads = [
    threading.Thread(target=consumer, args=(q, f"Consumer-{i}"))
    for i in range(2)
]
 
for t in threads:
    t.start()
 
for item in ["req-1", "req-2", "req-3", "req-4"]:
    q.put(item)
 
q.join()
 
for _ in threads:
    q.put(None)
 
for t in threads:
    t.join()
 
print("All work done.")

Consumer threads pull items in whichever order they happen to reach get() first, so this output pattern is just one possible interleaving:

texttext
Consumer-0 processing req-1
Consumer-1 processing req-2
Consumer-0 processing req-3
Consumer-1 processing req-4
All work done.

Two consumer threads pull items from a shared queue. The queue distributes items to whichever consumer calls get() first, so the output order depends on thread timing. The None sentinel values signal each consumer to exit.

q.join() blocks until every item has been marked with task_done().

Blocking behaviour and backpressure

Queue.get() blocks by default when the queue is empty. The calling thread sleeps until another thread calls put(). This blocking is efficient because the thread is not spinning in a loop checking the queue; the operating system wakes it only when data arrives.

Queue.put() blocks by default when the queue is full, but only if you set a maxsize. Without maxsize, the queue can grow without bound, which risks memory exhaustion if producers consistently outpace consumers. Setting maxsize provides backpressure: when the queue fills up, producers block until consumers make room, naturally throttling the production rate.

pythonpython
import queue
import threading
import time
 
q = queue.Queue(maxsize=2)
 
def producer():
    for i in range(5):
        print(f"Putting item {i}")
        q.put(i)
        print(f"Item {i} queued")
 
def consumer():
    time.sleep(1)
    while True:
        item = q.get()
        print(f"Got item {item}")
        time.sleep(0.5)
        q.task_done()
 
threading.Thread(target=consumer, daemon=True).start()
producer()

With maxsize=2 and a consumer that only processes one item every half second, the producer quickly outpaces it and has to wait. The output shows the producer blocking on put() once two items are already queued and unconsumed:

texttext
Putting item 0
Item 0 queued
Putting item 1
Item 1 queued
Putting item 2
Got item 0
Item 2 queued
Putting item 3
Got item 1
Item 3 queued
(remaining items follow the same blocked-then-queued pattern)

The producer blocks at put(item 2) until the consumer removes an item. Without maxsize, all five items would queue immediately regardless of the consumer's speed.

Both get() and put() accept a timeout parameter and a block parameter. get(timeout=5) waits up to 5 seconds and raises queue.Empty if no item arrives. get(block=False) returns immediately or raises queue.Empty.

These non-blocking variants are useful for graceful shutdown and health checks.

task_done() and join(): waiting for completion

task_done() and join() work together to let a producer wait for all queued work to be fully processed, not just dequeued. This distinction matters: get() removes an item from the queue, but the work that item represents may still be in progress. join() blocks until every get() has been matched by a task_done().

pythonpython
import queue
import threading
import time
 
def worker(q):
    while True:
        item = q.get()
        if item is None:
            q.task_done()
            break
        time.sleep(0.1)
        q.task_done()

Three worker threads start and immediately block on q.get(). The main thread puts 10 items on the queue, then calls q.join(), which only returns once every one of those 10 get() calls has had a matching task_done():

pythonpython
q = queue.Queue()
 
threads = [threading.Thread(target=worker, args=(q,)) for _ in range(3)]
for t in threads:
    t.start()
 
for i in range(10):
    q.put(i)
 
q.join()
 
for _ in threads:
    q.put(None)
for t in threads:
    t.join()
 
print("All 10 items processed.")

The q.join() call blocks until every one of the 10 items has had task_done() called on it. If a worker thread crashes before calling task_done(), the join() call blocks forever. Always call task_done() in a finally block or use a sentinel-based shutdown that ensures every get() is paired with task_done().

A common mistake is calling task_done() after get() even when an exception occurs during processing. This unblocks join() prematurely and leaves work unprocessed. The correct pattern is to call task_done() in a finally block after processing completes, or to put the item back into the queue if processing fails.

PriorityQueue: processing urgent work first

When some work items are more important than others, PriorityQueue ensures they are processed first. Items are tuples of (priority_number, data), and the queue returns the item with the smallest priority number next.

pythonpython
import queue
import threading
import time
 
q = queue.PriorityQueue()
 
tasks = [
    (2, "Low priority backup"),
    (0, "Critical alert"),
    (1, "Normal report"),
    (0, "Another critical alert"),
]
 
for priority, task in tasks:
    q.put((priority, task))
 
while not q.empty():
    priority, task = q.get()
    print(f"[Priority {priority}] {task}")
    q.task_done()

Both items tagged priority 0 come out before priority 1 or 2, and since they are inserted in the order shown, the queue happens to return them in that same order here:

texttext
[Priority 0] Another critical alert
[Priority 0] Critical alert
[Priority 1] Normal report
[Priority 2] Low priority backup

Items with priority 0 come out first, then priority 1, then priority 2. When two items share the same priority, the order between them is not guaranteed; PriorityQueue compares the entire tuple, and if the first elements are equal, it compares the second elements. To avoid comparison errors when data items are not comparable, include a tie-breaking counter:

pythonpython
import queue
import itertools
 
counter = itertools.count()
q = queue.PriorityQueue()
 
q.put((0, next(counter), "First critical"))
q.put((0, next(counter), "Second critical"))

The counter ensures a total ordering even when priorities match, so PriorityQueue never tries to compare the data items directly.

LifoQueue: stack-based processing

LifoQueue retrieves the most recently added item first. It is useful for depth-first work traversal and for scenarios where newer data is more relevant than older data.

pythonpython
import queue
 
q = queue.LifoQueue()
 
for item in ["first", "second", "third"]:
    q.put(item)
 
while not q.empty():
    print(q.get())

The three items come back in the reverse of the order they were queued, since each get() call removes the item that was added most recently rather than the one added first:

texttext
third
second
first

The item added last comes out first. This pattern suits work-stealing schedulers, undo-stack implementations, and cache eviction policies where recency matters.

Producer-consumer with multiple producers

The queue pattern scales naturally to multiple producers. Each producer puts work into the same queue, and consumers pull from it. Neither producers nor consumers need to know how many of the other exist.

pythonpython
import queue
import threading
import time
import random
 
def producer(q, producer_id, count):
    for i in range(count):
        item = f"P{producer_id}-T{i}"
        q.put(item)
        time.sleep(random.uniform(0.01, 0.05))
 
def consumer(q, consumer_id):
    while True:
        item = q.get()
        if item is None:
            q.task_done()
            break
        print(f"C{consumer_id} got {item}")
        time.sleep(random.uniform(0.02, 0.08))
        q.task_done()

Starting the consumers before the producers means they are already waiting on get() as soon as the first item arrives, instead of racing to start up after work has already been queued:

pythonpython
q = queue.Queue()
 
consumers = [
    threading.Thread(target=consumer, args=(q, i)) for i in range(2)
]
for t in consumers:
    t.start()
 
producers = [
    threading.Thread(target=producer, args=(q, i, 3)) for i in range(3)
]
for t in producers:
    t.start()
for t in producers:
    t.join()
 
q.join()
 
for _ in consumers:
    q.put(None)
for t in consumers:
    t.join()

Three producers generate 3 items each, and 2 consumers process them. The queue smooths out the arrival rate differences. If producers generate items faster than consumers can process them, the queue buffers the excess up to maxsize.

If consumers catch up, they block on get() until more items arrive.

Pipelines: chaining queues for multi-stage processing

A single queue handles one processing stage. For multi-stage pipelines, chain queues together: the output of one stage feeds the input of the next. Each stage runs in its own set of threads.

pythonpython
import queue
import threading
 
def read_filenames(out_q):
    for name in ["data.csv", "report.json", "log.txt"]:
        out_q.put(name)
    out_q.put(None)

The first stage only produces filenames and signals completion with None. The next two stages follow the same shape: read from an input queue, transform, and forward to an output queue until the sentinel arrives:

pythonpython
def parse_files(in_q, out_q):
    while True:
        filename = in_q.get()
        if filename is None:
            in_q.task_done()
            out_q.put(None)
            break
        result = f"Parsed {filename}"
        out_q.put(result)
        in_q.task_done()
 
def save_results(in_q):
    while True:
        result = in_q.get()
        if result is None:
            in_q.task_done()
            break
        print(f"Saving: {result}")
        in_q.task_done()

Each stage only knows about the queue it reads from and the queue it writes to, which is what lets parse_files sit between read_filenames and save_results without any of the three functions calling each other directly:

pythonpython
q1 = queue.Queue()
q2 = queue.Queue()
 
threading.Thread(target=read_filenames, args=(q1,)).start()
threading.Thread(target=parse_files, args=(q1, q2)).start()
threading.Thread(target=save_results, args=(q2,)).start()

Each stage is independently testable. You can swap parse_files for a different implementation without changing read_filenames or save_results. The queues enforce the data flow, and the sentinel values propagate through the pipeline to signal each stage to shut down.

When to use queues vs when to use locks

Queues and locks solve different problems. Use a queue when threads need to pass work between each other and the primary concern is data flow. Use a lock when threads share a resource that is not naturally a message, like a database connection or a configuration dictionary that multiple threads read and occasionally update.

Queues are the right default for most threaded Python programs because they make data flow explicit. Reading code that uses queues, you can see where data is produced and where it is consumed. Reading code that uses locks, you must trace every access to a shared variable to understand the synchronization protocol.

The article on synchronizing threads with locks covers the cases where locks are necessary despite this complexity.

Queues also compose better than locks. You can add a new processing stage by inserting a new queue between existing stages. You can add more consumers to a bottleneck stage without changing any other code.

Locks do not compose this way; adding a new lock to an existing locking scheme is a delicate operation that risks deadlock.

For programs that span multiple processes rather than threads, the multiprocessing module provides multiprocessing.Queue, which works across process boundaries with the same API as queue.Queue.

Rune AI

Rune AI

Key Insights

  • queue.Queue provides a thread-safe channel for passing data between threads without manual locking.
  • The producer-consumer pattern with queues separates work generation from work processing, making both sides independently testable.
  • task_done() and join() let producers wait for all queued work to complete without tracking individual threads.
  • Set maxsize on queues to provide backpressure and prevent unbounded memory growth when producers outpace consumers.
  • Prefer queues over shared mutable state with locks; queues make data flow explicit and eliminate most race conditions.
RunePowered by Rune AI

Frequently Asked Questions

Why use queue.Queue instead of a list with a lock?

queue.Queue is thread-safe by design and handles all synchronization internally. A list with a manual lock requires you to implement blocking behaviour when the list is empty, signal waiting threads when new items arrive, and manage timeouts. Queue provides all of this out of the box in a well-tested implementation. It also supports optional maximum size, which provides backpressure when producers outpace consumers.

What is the difference between Queue.task_done() and Queue.join()?

task_done() is called by a consumer thread after it finishes processing one item retrieved with get(). join() is called by a producer thread to block until every item that was put into the queue has had task_done() called on it. Together they let a producer wait for all submitted work to complete without knowing how many consumer threads are active.

Can I use queue.Queue with asyncio?

No. queue.Queue is designed for threading and uses blocking operations that would stall an asyncio event loop. For asyncio, use asyncio.Queue instead, which provides the same API but with async methods (await q.put(), await q.get()) that cooperate with the event loop.

Conclusion

queue.Queue is the cleanest way to move data between Python threads. It replaces shared mutable state guarded by locks with a channel-based design where threads communicate by passing messages. This pattern eliminates most race conditions because threads do not access the same memory directly. For the large majority of multithreaded programs, starting with queues before reaching for raw locks produces code that is simpler, safer, and easier to debug.