Last Updated: May 17, 2026
Queue<T> is the generic first-in-first-out collection in the BCL. Items go in at one end and come out at the other end in the same order they were added, which is exactly the shape you want for order-processing pipelines, support-ticket triage, breadth-first search, and any "handle these in the order they arrived" workflow. This lesson covers the type's construction, the core enqueue and dequeue operations, the safe Try* variants, the inspection methods, the internal circular-buffer layout that makes the operations O(1), and the pitfalls that bite people the first time they reach for it.
A queue is an ordered collection with two fixed ends. New items always join at the back (sometimes called the tail). Items always leave from the front (sometimes called the head). The order items leave in is exactly the order they arrived in, which is what "first in, first out" means.
A support-ticket workflow is a clean example. Tickets are filed as they arrive. Agents pick them up in arrival order. The ticket that was filed first is the one that gets worked on first, so the queue's job is to hand them back in that order.
Enqueue adds an item to the back. Peek reads the front item without removing it. Dequeue reads and removes the front item, which is the one that was added earliest of the items still in the queue. After dequeuing T-1001, the next call to Peek or Dequeue would return T-1002, because that was the next-oldest ticket still waiting.
Visually, the queue looks like a line at a checkout counter. The head is the customer being served, the tail is whoever just walked up. Nobody jumps the line, and you can only leave from the front.
The diagram shows the same queue in two states. Before the call, three tickets sit between the head pointer on the left and the tail pointer on the right. Dequeue returns the front item (T-1001) and moves the head forward, so the queue now starts at T-1002. The tail didn't move because no item was added.
This is the only access pattern the type supports. There's no indexer, no RemoveAt, no "remove the third item." Queue<T> is built for the case where order of arrival is the only ordering you care about, and trying to bend it into something else (like "skip ahead to the urgent ticket") usually means you wanted a different collection, such as PriorityQueue<TElement, TPriority>.
Queue<T> lives in System.Collections.Generic. It has three constructors worth knowing. The default constructor creates an empty queue with a small initial capacity. The capacity-taking constructor pre-sizes the internal buffer when you have a rough idea of how many items will be added. The IEnumerable<T> constructor seeds the queue with an existing sequence in the order the sequence yields them.
The default constructor returns an empty queue. The capacity-taking constructor also returns an empty queue but reserves space inside, so the first hundred enqueues don't have to resize the internal array. The third constructor walks the input sequence and enqueues each item in turn, so morningBacklog[0] becomes the head and morningBacklog[2] becomes the tail.
The capacity is an optimization, not a limit. A queue created with new Queue<string>(100) can still hold the 101st item just fine. The buffer will grow when needed, the same way List<T> grows. Pre-sizing matters when you know the upper bound and want to avoid the repeated allocations and copies that come with growing from the default size.
Cost: Pre-sizing with the capacity constructor is worth it when you'll add a lot of items in a short window (say, draining a batch of orders into a worker queue). For small queues, the cost of growing a few times is negligible and you can leave the default alone.
When seeding from an existing sequence, the queue copies the items into its own buffer. Modifying the original array afterwards doesn't change what's in the queue.
The queue holds its own copies of the references, so reassigning inbound[0] after construction doesn't touch the queue's first item. The queue and the list are now independent containers that just happened to share starting data.
There's no constructor that takes a comparer, because a queue doesn't compare items. Order is determined by arrival, not by value. The element type also has no IComparable<T> requirement. You can put any type into a Queue<T>, including reference types that don't define equality at all.
Enqueue(T item) appends an item to the back of the queue. The new item becomes the tail. The head doesn't move. The count increases by one.
Notice that the head stays ORD-501 through all three enqueues. New items go to the tail, so the head only changes when a Dequeue advances it. This is the half of FIFO that "first in" describes: the first item in is still the next item out, no matter how many later items arrive.
Enqueue runs in amortized constant time, which is the same guarantee List<T>.Add makes. Most calls just write into the next slot of the internal buffer and bump a counter. Occasionally a call has to grow the buffer (allocate a new, larger array and copy everything over), but the cost is spread across many cheap calls, so the average stays O(1).
Cost: Enqueue is O(1) amortized. The single call that triggers a resize is O(n), where n is the current count. If you pre-size the queue with the capacity constructor and stay under that capacity, you avoid the resize entirely.
Enqueue accepts null for reference types and never throws based on the item's value. The only way it can throw is OutOfMemoryException if the runtime can't allocate the larger buffer during a resize, which is the same failure mode any growing collection has.
The middle slot holds a real null, not "no slot at all." Count includes it, iteration yields it, and a future Dequeue would return it. Allowing null matches List<T> and most BCL collections. If you want a queue that rejects null, wrap Enqueue in a method that throws ArgumentNullException first.
Dequeue() removes the head item, returns it, and advances the head pointer to the next item. The tail doesn't move. The count decreases by one.
Three items, three dequeues, in arrival order. The queue ends up empty, which is the normal terminal state of a worker that drains its inbox.
The dangerous case is calling Dequeue on an empty queue. The method throws InvalidOperationException with the message "Queue empty.".
InvalidOperationException is the standard type for "this operation isn't valid in the current state." A queue with no items is in a state where there's nothing to return, so the operation is rejected.
The fix is one of two patterns. Either guard the call with a count check, or use the TryDequeue variant covered later in this lesson. The count check is fine when you're sure no other code is touching the queue between the check and the call:
The check-then-act pattern works fine for single-threaded code, because Count and Dequeue see the same queue state. For multi-threaded scenarios, Queue<T> is not safe and you'd reach for ConcurrentQueue<T>, which collapses the check and the remove into a single atomic TryDequeue call.
Dequeue runs in true O(1), not amortized. There's no resize on the dequeue path. The method reads the slot at the head index, clears the reference (so the dequeued object can be garbage-collected), advances the head, and returns. None of those steps depend on how many items are in the queue.
Cost: Dequeue is O(1) per call, with no amortized caveat. Draining a queue of n items takes O(n) total, which is what you'd expect.
TryDequeue(out T result) does the same job as Dequeue but returns a bool instead of throwing on an empty queue. When the queue has at least one item, it removes the head, assigns it to result, and returns true. When the queue is empty, it sets result to default(T) and returns false. The pattern matches Dictionary<TKey, TValue>.TryGetValue and the parser methods like int.TryParse.
The while (jobs.TryDequeue(out var job)) loop is the canonical drain pattern. It removes one item per iteration, processes it, and stops automatically when the queue empties. There's no separate Count > 0 check, no risk of an InvalidOperationException if some other branch already drained the queue, and the loop variable job is in scope inside the loop body thanks to the out var declaration.
TryDequeue was added in .NET Core 2.1. On older targets it isn't available, and you'd use a while (q.Count > 0) loop with Dequeue inside. On .NET 8+, TryDequeue is the more direct choice and you should reach for it first.
TryPeek(out T result) is the same pattern for Peek. It returns the head without removing it, returning true on a non-empty queue and false on an empty one.
The first call sees an empty queue and returns false, so the else branch runs. After one enqueue, the second call sees T-9001 at the head and returns true, so the if branch prints the ticket id. Neither call removed anything, so Count is still 1 after both.
TryPeek is useful when "do we have any work?" and "what's the next piece of work?" need to be combined into one decision. The check-then-peek pattern with Count > 0 is fine for single-threaded code, but TryPeek reads better and matches the other Try* methods in the BCL.
| Operation | On empty queue | Return type | When to use |
|---|---|---|---|
Dequeue() | Throws InvalidOperationException | T | You're sure the queue is non-empty |
Peek() | Throws InvalidOperationException | T | You're sure the queue is non-empty |
TryDequeue(out T) | Returns false, sets result to default(T) | bool | Empty is a normal case |
TryPeek(out T) | Returns false, sets result to default(T) | bool | Empty is a normal case |
The four operations divide cleanly along two axes. Dequeue vs Peek controls whether the head is removed. Try vs no Try controls whether an empty queue is a thrown exception or a returned false. Most production code uses the Try* variants, because "queue might be empty" is usually a routine condition rather than a bug.
Peek() returns the head item without removing it. The count doesn't change, the head doesn't move, and the next Dequeue would still return the same item. It throws InvalidOperationException on an empty queue, same as Dequeue.
Wait, that's wrong. Three Peek calls should all return msg-1, because Peek doesn't remove anything. The actual output is:
Actual Output:
Peek is read-only. Calling it ten times in a row returns the same value ten times and leaves the queue's state untouched. The count is still 3 after three peeks.
The right time to use Peek is when you need to decide what to do based on the head before committing to removing it. A worker might peek to check whether a job is ready to run (for example, its scheduled time has arrived), and only dequeue once it's confirmed.
The loop peeks at the head, checks whether it's due, and only commits to a Dequeue if the answer is yes. When the head isn't due, the loop breaks and leaves the job in place for the next pass. This pattern shows up wherever a queue has a "should I act on this yet?" check that's cheaper to do without removal.
Peek is also useful in debugging. Printing the head of a queue without disturbing it is the kind of inspection you reach for when tracking down "why is this thing stuck."
Count is the number of items currently in the queue. It's an O(1) property read, not a walk through the buffer.
Count is updated by Enqueue, Dequeue, and Clear. The internal buffer might be much larger than Count (since the queue grows by doubling), but Count only reflects the slots holding items, not the total buffer capacity.
Contains(T item) returns true if the queue holds at least one item equal to the argument, and false otherwise. Equality is determined by EqualityComparer<T>.Default, which means it uses the type's Equals method (or reference equality for reference types that don't override it).
Contains walks the queue from head to tail, comparing each slot to the argument and stopping on the first match. It's O(n) in the worst case, which is when the item isn't there and every slot has to be checked. There's no faster path. A queue isn't a hash set, and it doesn't keep an index of its contents.
Cost: Contains is O(n). If you find yourself calling it on a queue inside a loop, the combined cost is O(n*m), and you usually want a HashSet<T> alongside the queue (or instead of it) for the lookup half of the work.
Clear() removes every item from the queue and resets it to an empty state. The count goes to 0, the head and tail pointers go back to the start, and any references that were in the buffer are cleared so the items can be garbage-collected.
Clear leaves the internal buffer allocated, so the queue is still ready to accept new items without re-allocating. If you want to release the buffer memory, set the queue reference to null and let the GC reclaim everything, or create a fresh queue.
Clear is O(n) in the worst case because it has to overwrite each slot to null (for reference types) or default(T) (for value types) so the garbage collector can reclaim what was there. For value-type element types like int, the JIT often elides the per-slot clearing, but the rule of thumb is "treat Clear as O(n)."
Queue<T> implements IEnumerable<T>, so a foreach loop yields items in order from head to tail without removing any of them. The queue's state is exactly the same before and after the loop, assuming nothing inside the loop body mutates it.
The loop yields the head first, then each subsequent item in arrival order, ending at the tail. The count and the head are unchanged, because iteration is read-only. This is different from how some people initially picture a queue, where "looking at the queue" might mean draining it. Iteration here is a read-only walk.
ToArray() returns a new array containing every item in head-to-tail order. The array is independent of the queue, so modifying it doesn't affect the queue and vice versa.
ToArray walks the queue once and copies the references into a fresh array sized exactly to Count. After construction, the array and the queue are completely independent. Mutating the array doesn't reach back into the queue (the queue's ORD-1 slot was cleared by the Dequeue, not by the array assignment), and mutating the queue doesn't reach back into the array.
Cost: ToArray is O(n) in time and allocates a new array of exactly Count elements. It's the right call for a one-time snapshot. It's the wrong call inside a hot loop that runs once per item, because that's O(n^2) total.
The big rule for iteration is "don't mutate the queue while iterating it." Calling Enqueue, Dequeue, or Clear inside a foreach over the same queue throws InvalidOperationException with the message "Collection was modified; enumeration operation may not execute.". The enumerator tracks a version number on the queue and bails out the moment the queue's version doesn't match what it captured.
The first iteration prints ORD-1, then the body enqueues ORD-NEW, and the next MoveNext call on the enumerator notices the queue's version has changed and throws. This is fail-fast behavior: the iterator doesn't try to recover, because the new item might have caused the underlying buffer to resize, invalidating the indices the iterator was using.
The fix when you genuinely need to add or remove while walking is to take a snapshot first with ToArray and iterate that:
The snapshot freezes the items at the moment of the call, so the loop walks the original three, and the new enqueue inside the loop is invisible to the iteration. The queue is allowed to change during the loop because the loop is iterating the array, not the queue.
This pattern works but it's worth questioning whether the structure is right. If you're often peeking, deciding, and enqueuing, you might be conflating two queues into one, and splitting them into "input" and "follow-up" queues can make the data flow easier to reason about.
Queue<T> is implemented on top of a single T[] array, plus three integers: a head index, a tail index, and a count. New items are written at the tail and the tail index advances. Removed items are read at the head and the head index advances. When either index walks off the end of the array, it wraps around to index 0. This is what makes the data structure a circular buffer (sometimes called a ring buffer).
The naive alternative would be to shift every remaining item down by one slot every time Dequeue runs, so the head is always at index 0. That works but it's O(n) per dequeue, which defeats the whole point of using a queue. The circular layout keeps Enqueue and Dequeue both at O(1) by moving the indices instead of moving the items.
A small example makes the wrap-around concrete. Picture a queue with a buffer of length 4, into which you enqueue three items, dequeue two, and then enqueue two more.
State 1: three items have been enqueued. They sit in slots 0, 1, and 2. The head index is 0 (slot of A, the next to leave). The tail index is 3 (slot where the next enqueue will write).
State 2: two dequeues have run. A and B are gone, their slots have been cleared, and the head index now points at slot 2 (where C is). The tail didn't move, because nothing was enqueued. Count is 1.
State 3: two more enqueues. D goes into slot 3 (the current tail position) and the tail advances. Slot 4 doesn't exist (the array has indices 0 to 3), so the tail wraps around to 0. E goes into slot 0 and the tail advances to 1. Count is now 3, head is still 2 (where C is, the oldest item), and tail is 1 (the next slot for a future enqueue). The items live in slots 2, 3, 0 in that logical order, which is exactly the order Dequeue will return them.
The wrap-around is the interesting bit. The head and tail aren't constrained to stay in order in the underlying array. The "logical" order (C then D then E) is reconstructed by starting at the head index and walking forward, wrapping at the end of the array. That's how foreach, Contains, and ToArray work internally. They start at head, walk count slots forward, and treat index length - 1 as adjacent to index 0.
When count equals the array's length, the buffer is full. The next Enqueue allocates a new, larger array (typically double the size), copies the items over in logical order starting at index 0, resets head to 0 and tail to the old count, and then writes the new item. That's the O(n) resize path that makes Enqueue amortized O(1) rather than always O(1).
Cost: The resize is rare for steady-state queues that drain about as fast as they fill, because the count stays bounded and the buffer never grows past the working size. For burst-and-drain patterns where the queue grows to N and then empties, pre-sizing with new Queue<T>(N) skips the resizes entirely.
You don't have to think about head, tail, or wrap-around when using the type. The public surface (Enqueue, Dequeue, Peek, Count) hides all of it. But understanding the layout helps explain three things: why Dequeue is O(1) (it just moves the head index), why iteration order is "head to tail" rather than "index 0 to index n-1" (the array's first slot might not be the head), and why mutating during iteration throws (the iterator captured a head index, and a resize would invalidate it).
Queue<T> shows up wherever items need to be processed in arrival order. A handful of patterns cover most real uses.
Breadth-first search. BFS visits a starting node, then all of its neighbors, then all of their unvisited neighbors, and so on. The queue holds the "frontier" of nodes waiting to be visited. Because the queue is FIFO, every node at depth d is visited before any node at depth d+1, which is what gives BFS its level-order property.
root is visited first. Then electronics, home, clothing (all of root's children). Then phones and laptops (electronics' children), kitchen and bedding (home's children), and so on. The order is "all of depth 1, then all of depth 2, then all of depth 3," which is exactly what BFS produces and exactly what a FIFO queue gives you.
If you swapped the Queue<T> for a Stack<T>, the same code would produce a depth-first traversal instead. The data structure is what makes the algorithm BFS, not the algorithm itself.
Order-processing pipeline. Orders arrive on a website, and a worker picks them up one at a time to charge the card, deduct stock, and notify the customer. The queue is the in-process buffer between the web request handler (which enqueues) and the worker loop (which dequeues).
For a single-threaded simulation this is just a loop, but the shape is what matters. The producer (the web handler) and the consumer (the worker) communicate through the queue, and FIFO guarantees that orders are worked on in the order they arrived. In a real system you'd use ConcurrentQueue<T> to make the two threads safe to share, but the FIFO semantics are the same.
Support-ticket triage. Inbound tickets land in a queue, and a small pool of agents takes whichever ticket is at the head. Because the queue is FIFO, the customer who filed first gets answered first, which is the intuitively fair behavior. If you wanted "VIP tickets first" you'd use a priority queue instead, but the default is plain FIFO.
Bounded-history buffer for recently-viewed products. A user's "recently viewed" list shows the last N products they looked at. As they view a new product, you Enqueue the product id. If the queue's count exceeds N, you Dequeue the oldest one. The queue stays a fixed size and always holds the most recent N items in the order they were viewed.
The first two View calls grow the queue to size 2. The third fills it to capacity. From the fourth call onwards, every new view evicts the oldest entry, so the queue holds exactly the last three viewed products. The diagram from the FIFO section earlier in this lesson is exactly the right mental model: new items join at the back, oldest items leave from the front.
When you don't want FIFO, don't reach for Queue<T>. Stacks of operations to undo? That's Stack<T>. Items processed by priority rather than arrival? That's PriorityQueue<TElement, TPriority>. A set of items where order doesn't matter and lookups are common? That's HashSet<T>. Queue<T> is the right answer when "the order things arrived in is the order they should be handled in" describes what you want.
A handful of mistakes come up the first few times people use Queue<T>. Each one is small but each one has a clean fix.
Forgetting to check for empty before `Dequeue` or `Peek`. Both methods throw InvalidOperationException when the queue is empty, and the message ("Queue empty.") doesn't always make the cause obvious in a deep stack trace. The fix is either an explicit Count > 0 guard or, more often, the TryDequeue / TryPeek variant. When in doubt, prefer Try* because it makes "empty" a return value rather than an exception.
Mutating the queue during iteration. foreach captures the queue's version when it starts, and any Enqueue, Dequeue, or Clear during iteration invalidates the enumerator on its next MoveNext. The exception you get is InvalidOperationException with the message "Collection was modified; enumeration operation may not execute.". The fix is to iterate ToArray() instead, or to redesign so that the producing code and the consuming code don't share a single in-flight iteration.
Treating `Queue<T>` as thread-safe. It isn't. Two threads enqueuing concurrently, or one thread enqueuing while another dequeues, can corrupt the internal state. The symptoms range from lost items to IndexOutOfRangeException deep inside the BCL. The right type for that situation is ConcurrentQueue<T> from System.Collections.Concurrent. Don't try to fix the threading by wrapping Queue<T> in lock statements unless you're very sure you've covered every access; the concurrent type is faster, simpler, and already correct.
Confusing `Peek` with `Dequeue`. Peek reads the head without changing the queue. Dequeue reads and removes. Mixing them up shows up as either "I keep processing the same item" (called Peek where Dequeue was needed) or "I lost an item" (called Dequeue where Peek was needed, and the catch path doesn't put it back). When in doubt, pick the operation that matches the intent: "what's next?" is Peek; "give me the next one to work on" is Dequeue.
Using `Contains` for membership in hot loops. Contains is O(n) on a queue, and a loop that calls Contains once per inserted item is O(n*m). If you need both arrival-order processing and fast membership tests, keep a HashSet<T> alongside the queue, adding to both on Enqueue and removing from the set on Dequeue. The BFS example earlier in this lesson does exactly this pattern with the visited set.
Assuming the buffer shrinks. It doesn't, automatically. Dequeue and Clear reduce Count, but the internal array stays at whatever size it grew to. For a queue that bursts to a million items and then drains to zero, the buffer is still million-sized. If memory matters, copy what's left into a new queue with new Queue<T>(q) and let the old one go to garbage collection.
The new queue's buffer is sized to fit the 1000 remaining items (plus a small headroom from the constructor), and the old buffer becomes eligible for collection as soon as big goes out of scope. This is rarely worth doing for small queues, but it matters for long-lived ones that experienced a one-time spike.
ConcurrentQueue<T> is the thread-safe sibling of Queue<T>. It exposes Enqueue, TryDequeue, and TryPeek with concurrent-safe semantics, and it doesn't expose Dequeue or Peek (because those would have to throw on a "just emptied by another thread" race that the caller couldn't see coming). Use it whenever a queue is shared across threads.
ImmutableQueue<T> is the persistent-data-structure variant. Enqueue and Dequeue return new queues rather than mutating in place, which makes it the right choice when you want to capture a snapshot of a queue's state and pass it around without worrying about later changes. The cost is allocation per operation, so it's not a drop-in replacement for Queue<T> in hot paths.
The takeaway is just: when "I need a FIFO queue" comes with "and it's shared between threads" or "and I want a snapshot," there's a purpose-built type and you don't have to bolt thread-safety or immutability onto Queue<T> yourself.
Queue<T> is the generic FIFO collection in System.Collections.Generic. Enqueue adds at the tail, Dequeue removes from the head, and items leave in arrival order.Enqueue and Dequeue are both O(1) (Enqueue amortized, Dequeue exact), backed by a circular buffer with head and tail indices that wrap around the end of the internal array.Dequeue and Peek throw InvalidOperationException on an empty queue. The TryDequeue and TryPeek variants return false on empty instead, which is the better default when empty is a normal condition.Count is O(1), Contains is O(n), and Clear is O(n). The internal buffer doesn't shrink automatically; copy into a new queue if you need to release capacity after a spike.foreach iterates the queue from head to tail without removing items, and throws if the queue is mutated mid-iteration. Use ToArray() to snapshot and iterate independently when you need to add or remove during the walk.Queue<T> is not thread-safe. For shared-thread access, use ConcurrentQueue<T>. For snapshot-friendly persistent semantics, use ImmutableQueue<T>.