A circular queue solves a subtle but annoying problem with a naive array-based queue: wasted space. In a normal array queue, once you dequeue a few elements from the front, those slots sit empty forever — the queue reports "full" long before the underlying array actually is, because new elements only ever get added at the growing end.

A circular queue fixes this by treating the array as if its end wraps back around to its beginning, like a clock face. Once the "rear" pointer reaches the last index, the next enqueue wraps around to index 0 — as long as that slot has already been freed by a dequeue.

The Problem With a Naive Array Queue

Picture a fixed-size array of length 5 used as a queue. You enqueue 5 items, filling it completely, then dequeue 3 from the front. Three slots are now free — but a naive implementation that only tracks a single growing "rear" index can't reuse them, because rear is already sitting at index 5 (out of bounds). The queue looks full even though 3 out of 5 slots are empty.

How Wrapping Around Fixes It

A circular queue tracks two indices, front and rear, and advances each one using the modulo operator so it wraps back to 0 after hitting the array's length:

class CircularQueue:
    def __init__(self, capacity):
        self.capacity = capacity
        self.queue = [None] * capacity
        self.front = 0
        self.rear = 0
        self.size = 0

    def enqueue(self, value):
        if self.size == self.capacity:
            raise OverflowError("Queue is full")
        self.queue[self.rear] = value
        self.rear = (self.rear + 1) % self.capacity  # wrap around
        self.size += 1

    def dequeue(self):
        if self.size == 0:
            raise IndexError("Queue is empty")
        value = self.queue[self.front]
        self.front = (self.front + 1) % self.capacity  # wrap around
        self.size -= 1
        return value

cq = CircularQueue(5)
for i in [10, 20, 30, 40, 50]:
    cq.enqueue(i)

print(cq.dequeue())
print(cq.dequeue())
print(cq.dequeue())

# These slots are now free (indices 0, 1, 2) and can be reused:
cq.enqueue(60)
cq.enqueue(70)
print("rear index:", cq.rear)

Output:

10
20
30
rear index: 2

Notice rear wrapped from 5 back to 0, then advanced to 1, then to 2 — reusing the slots freed by the three dequeues, instead of reporting the queue as full.

Full vs Empty: The Tricky Edge Case

Because front and rear can both land on the same index when the queue is either completely empty or completely full, implementations typically track a separate size counter (as above) to tell the two states apart — relying purely on front == rear is ambiguous.

Circular queues are a core building block for fixed-size buffering problems, such as streaming data through a bounded buffer. If you haven't yet, it's worth first getting comfortable with the regular FIFO queue this structure is built on top of — the enqueue/dequeue logic is identical, only the index math changes.