Queues
The standard-library queue module provides thread-safe FIFO, LIFO, and priority queues purpose-built for coordinating work between threads, most often in a producer/consumer design.
The queue module implements thread-safe queue classes for use with Python's threading model: Queue (FIFO), LifoQueue (stack-like), PriorityQueue (ordered by priority), and SimpleQueue (a minimal, fast FIFO). Unlike a plain list, these queues coordinate safe access from multiple threads internally, and are the standard building block for the classic producer/consumer pattern.
What Is the queue Module and Why It Exists
Sharing a plain list between threads for producer/consumer coordination requires manually adding locks around every access, plus a separate mechanism for a consumer to wait efficiently when the list is empty. The queue module's classes bundle exactly that coordination — mutual exclusion plus blocking wait/notify — behind a simple put()/get() interface, purpose-built for passing work items or results safely between threads.
Basic Queue (FIFO)
queue.Queue is a first-in-first-out queue. put(item) adds an item (blocking if the queue is full and a maxsize was set); get() removes and returns the oldest item (blocking if the queue is empty until something is put).
import queue
q: queue.Queue[int] = queue.Queue()
q.put(1)
q.put(2)
q.put(3)
print(q.get()) # 1
print(q.get()) # 2
print(q.qsize()) # 1 (approximate under concurrent access)LifoQueue
queue.LifoQueue behaves like a thread-safe stack — get() returns the most recently put item first (last-in-first-out) rather than the oldest.
import queue
stack: queue.LifoQueue[int] = queue.LifoQueue()
stack.put(1)
stack.put(2)
stack.put(3)
print(stack.get()) # 3
print(stack.get()) # 2PriorityQueue
queue.PriorityQueue returns the smallest item first, as determined by ordinary Python comparison (typically a (priority_number, item) tuple, so lower priority_number comes out first). Items must be comparable — a common pattern uses a tuple whose first element is the sort key.
If two items have the same priority number, Python falls back to comparing the rest of the tuple; if the second elements aren't comparable (e.g. two dicts), this raises TypeError. Including a unique tiebreaker (like an insertion counter) as the second tuple element avoids that.
import queue
pq: queue.PriorityQueue = queue.PriorityQueue()
pq.put((2, "medium priority"))
pq.put((1, "high priority"))
pq.put((3, "low priority"))
while not pq.empty():
priority, label = pq.get()
print(priority, label)
# 1 high priority
# 2 medium priority
# 3 low prioritySimpleQueue
queue.SimpleQueue (added in 3.7) is a minimal, unbounded FIFO queue with only the essential operations (put, get, empty, qsize) and no maxsize, no task_done()/join() tracking, and no support for being interrupted mid-put. It's typically faster than Queue for the simple case and is specifically documented as safe to use from a signal handler or reentrant contexts where full Queue is not guaranteed safe.
Blocking vs Non-blocking Access
By default, get() blocks forever if the queue is empty, and put() blocks forever if the queue is full (only relevant when maxsize is set). get_nowait() / put_nowait() never block, raising queue.Empty or queue.Full immediately instead; get(timeout=...) / put(timeout=...) block for at most that many seconds before raising the same exceptions.
import queue
q: queue.Queue[int] = queue.Queue(maxsize=1)
q.put(1)
try:
q.put_nowait(2) # queue is full
except queue.Full:
print("queue is full, could not add another item")
try:
q.get(timeout=0.1)
q.get(timeout=0.1) # nothing left after 0.1s
except queue.Empty:
print("queue is empty, gave up waiting")Producer/Consumer Pattern
The canonical use of Queue: one or more producer threads put() work items in; one or more consumer threads get() and process them. task_done() (called by a consumer after finishing an item) and join() (called by the main thread to block until every put item has had a matching task_done()) together let the main thread know when all queued work has actually been processed, not just removed from the queue.
A None sentinel is a common convention for telling a consumer thread 'there is no more work' — one sentinel is needed per worker so each one sees its own shutdown signal.
import queue
import threading
def worker(q: queue.Queue) -> None:
while True:
item = q.get()
if item is None: # sentinel value signals shutdown
q.task_done()
break
print(f"processing {item}")
q.task_done()
work_queue: queue.Queue = queue.Queue()
threads = [threading.Thread(target=worker, args=(work_queue,)) for _ in range(3)]
for t in threads:
t.start()
for item in range(10):
work_queue.put(item)
work_queue.join() # blocks until every item has been task_done()
for _ in threads:
work_queue.put(None) # one sentinel per worker, to stop each cleanly
for t in threads:
t.join()Thread Safety
Every operation on Queue, LifoQueue, PriorityQueue, and SimpleQueue is internally synchronized (using a lock and condition variables), so multiple threads can safely call put() and get() concurrently without you adding any locking yourself. This is the entire point of using a queue instead of a plain list for cross-thread communication — a plain list's append()/pop() are not designed to guarantee this level of correctness under concurrent access, and offer none of the blocking-wait behavior a queue provides.
Practical Usage With ThreadPoolExecutor
In modern code, concurrent.futures.ThreadPoolExecutor often replaces hand-rolled producer/consumer thread pools for simple 'run these callables and collect results' tasks. queue.Queue remains the right tool when you need an open-ended, ongoing pipeline (unknown or unbounded work arriving over time) rather than a fixed batch of submitted calls.
Multiprocessing Queue Distinction
queue.Queue is for coordinating threads within a single process, sharing memory directly. multiprocessing.Queue (covered in the Multiprocessing IPC topic) looks similar but works across separate processes, which do not share memory, so it pickles items and sends them through an underlying pipe — a fundamentally different (and slower) mechanism despite the similar API.
Best Practices
Use a bounded maxsize when producers might outpace consumers, to apply backpressure instead of unbounded memory growth. Always pair task_done() with get() in consumer loops if you plan to use join(). Use a clear sentinel convention (or a shutdown flag) to signal consumer threads to stop, and send one sentinel per worker thread.
Complete Example
import queue
import threading
import time
def producer(q: queue.Queue, count: int) -> None:
for i in range(count):
q.put(i)
time.sleep(0.01)
def consumer(q: queue.Queue, results: list) -> None:
while True:
item = q.get()
if item is None:
q.task_done()
return
results.append(item * item)
q.task_done()
def main() -> None:
q: queue.Queue = queue.Queue(maxsize=5)
results: list = []
prod = threading.Thread(target=producer, args=(q, 10))
cons = threading.Thread(target=consumer, args=(q, results))
prod.start()
cons.start()
prod.join()
q.put(None)
cons.join()
print(sorted(results)) # [0, 1, 4, 9, ..., 81]
if __name__ == "__main__":
main()Mental Model
Think of a Queue as a thread-safe conveyor belt with a built-in doorbell: put() places an item and rings the bell if anyone's waiting; get() takes the next item, or waits by the belt (without spinning or wasting CPU) until the bell rings if nothing is there yet.
Quick Reference
Queue FIFO, thread-safe
LifoQueue LIFO (stack), thread-safe
PriorityQueue smallest item first, thread-safe
SimpleQueue minimal FIFO, no maxsize/task_done, signal-handler safe
put(item) / get() blocking add / remove
put_nowait / get_nowait raise Full / Empty instead of blocking
task_done() / join() mark item processed / wait for all items processedKey Takeaways
The queue module provides thread-safe FIFO (Queue), LIFO (LifoQueue), priority (PriorityQueue), and minimal (SimpleQueue) queues for coordinating threads.
All synchronization is handled internally — no manual locking is needed around put()/get() calls, unlike a plain list shared across threads.
task_done() and join() together let a producer know when all queued work has actually been processed, not just removed from the queue.
queue.Queue coordinates threads within one process; multiprocessing.Queue serves the analogous role across separate processes, at higher (serialization) cost.
import queue shared_queue: queue.Queue = queue.Queue() # thread A: shared_queue.put(item) # thread B: item = shared_queue.get() queue.Queue provides the locking and blocking-wait semantics a plain list does not; using a list for cross-thread handoff risks subtle races and gives consumers no efficient way to wait for new items.
q.join() q.put(None) # sentinel added AFTER join() — the consumer may already be # blocked waiting forever since join() never returns first q.put(None) # send shutdown sentinel first q.join() # then wait for it (and everything else) to be processed join() blocks until every item, including the sentinel, has had task_done() called — the sentinel must be enqueued before join() is awaited, or task_done() counts never balance and join() hangs.
What is the key difference between queue.Queue and queue.LifoQueue?
Queue is first-in-first-out (get() returns the oldest item); LifoQueue is last-in-first-out, behaving like a thread-safe stack (get() returns the most recently added item).
Why does queue.PriorityQueue commonly use (priority_number, item) tuples rather than putting items in directly?
PriorityQueue returns the smallest item first using ordinary comparison; wrapping each item with an explicit priority number as the first tuple element makes the ordering intentional and avoids relying on the items themselves being comparable.
What happens when you call get() on an empty Queue with no timeout?
It blocks (waits) indefinitely until another thread puts an item into the queue.
What is the purpose of task_done() and join() together?
task_done() marks one previously get()'d item as fully processed; join() blocks until every item that was put() has had a matching task_done() call, letting the main thread know all queued work is truly finished, not just dequeued.
Why is queue.SimpleQueue sometimes preferred over queue.Queue?
It's a minimal, typically faster FIFO with fewer features (no maxsize, no task_done/join), and it's specifically documented as safe to use from contexts like signal handlers where the full Queue's locking is not guaranteed safe.
How does multiprocessing.Queue differ from queue.Queue despite the similar API?
queue.Queue coordinates threads sharing the same process memory directly; multiprocessing.Queue coordinates separate processes that don't share memory, so it must pickle items and send them over an underlying pipe, adding serialization overhead.
Write a producer/consumer setup using queue.Queue where a single producer thread puts 5 items and a single consumer thread processes them, using a None sentinel to signal shutdown.
import queue import threading def producer(q): for i in range(5): q.put(i) q.put(None) def consumer(q): while True: item = q.get() if item is None: break print(f"got {item}") q = queue.Queue() p = threading.Thread(target=producer, args=(q,)) c = threading.Thread(target=consumer, args=(q,)) p.start(); c.start() p.join(); c.join()
The producer enqueues each item then a None sentinel; the consumer loops on get(), processing items until it sees the sentinel and breaks out — a standard pattern for cleanly stopping one consumer thread.
Both items share priority 1, and the second tuple element is a dict. What is likely to go wrong here, and why?
When two items tie on the first tuple element, PriorityQueue falls back to comparing the remaining elements to break the tie; dicts don't support ordering comparisons (<), so this raises a TypeError instead of returning a result — adding a unique tiebreaker (like a counter) as the second element avoids the problem.
Explain what happens if q.join() is called but one of the items put into the queue never has task_done() called on it.
join() blocks forever (or until interrupted), because it waits for the internal unfinished-task count to reach zero, and that count only decreases when task_done() is called once per completed get() — a missing task_done() call means the count never reaches zero.
import queue
pq = queue.PriorityQueue()
pq.put((1, {"name": "task-a"}))
pq.put((1, {"name": "task-b"}))
print(pq.get())