C++ STL Containers: Which One Should You Actually Use?
The C++ Standard Template Library gives you a toolkit of pre-built data structures called **containers**.
The C++ Standard Template Library gives you a toolkit of pre-built data structures called containers. Instead of writing your own linked list or hash table from scratch, you grab std::vector, std::map, or one of a dozen other containers that handle memory, iteration, and common operations for you. Each container has trade-offs in speed, memory layout, and what operations it does best. Your job is to pick the right tool for the job—because choosing badly can tank performance or bloat your code with unnecessary complexity.
Think of STL containers as specialized boxes for storing data. Some boxes let you access items by index in constant time. Others keep everything sorted automatically. Some optimize for fast insertion at the front, while others shine when you're mostly adding to the back. Understanding these differences transforms you from someone who just uses std::vector for everything into a developer who writes genuinely efficient C++.
- Performance scales: A
std::vectorlookup is O(1), but searching an unsortedstd::listis O(n). In a game loop running 60 times per second, that difference is the gap between smooth gameplay and frame drops. - Memory locality wins: Modern CPUs prefetch contiguous memory. Containers like
std::vectorexploit cache lines; node-based containers likestd::listscatter allocations and kill cache performance. - API clarity: Using
std::setsignals "unique sorted elements" to anyone reading your code. Using a raw array with manual sorting doesn't. - Iterator invalidation traps: Insert into a
std::vectorand all your iterators might become invalid. Understanding container guarantees prevents subtle bugs. - Zero-cost abstractions: STL containers compile down to tight machine code. You're not sacrificing speed for convenience—you're gaining type safety and correctness checks.
Sequence containers store elements in a specific order you control. You decide where things go, and the container maintains that sequence.
std::vector is your default choice. It's a dynamically-resizing array with contiguous memory. You get constant-time random access via operator[], and appending to the back is amortized O(1). The catch? Inserting in the middle is O(n) because you have to shift everything. Resizing triggers reallocation, which invalidates all iterators and pointers.
cpp#include <vector> #include <iostream> int main() { std::vector<int> scores = {10, 20, 30}; scores.push_back(40); // Fast: O(1) amortized scores.insert(scores.begin(), 5); // Slow: O(n), shifts everything for (size_t i = 0; i < scores.size(); ++i) { std::cout << scores[i] << " "; // Random access: O(1) } // Output: 5 10 20 30 40 }
std::deque (double-ended queue) lets you insert/remove efficiently at both front and back. Unlike std::vector, it doesn't store elements in one contiguous block—it uses chunks of memory. This means iterators stay valid when you grow the deque, but you lose some cache locality.
std::list is a doubly-linked list. Insertions and deletions anywhere are O(1) if you already have an iterator to that position. But there's no random access—reaching the middle is O(n). The killer downside? Every element requires separate heap allocation. Cache performance is terrible. Use it only when you need stable iterators (ones that never invalidate) or frequent mid-sequence splicing.
| Container | Random Access | Insert Front | Insert Back | Insert Middle | Memory Layout |
|---|---|---|---|---|---|
std::vector | O(1) | O(n) | O(1)* | O(n) | Contiguous |
std::deque | O(1) | O(1) | O(1) | O(n) | Chunked |
std::list | O(n) | O(1) | O(1) | O(1)** | Scattered nodes |
*Amortized when capacity allows
**With iterator to position
Associative containers organize elements by keys and keep them sorted. They're built on balanced binary search trees (usually red-black trees), so lookups, insertions, and deletions are all O(log n).
std::set stores unique keys with no duplicates. It's perfect when you need to track membership and occasionally iterate in sorted order. std::multiset allows duplicates.
std::map pairs keys with values. Think of it as a dictionary: look up an employee ID (key) and get their salary (value). std::multimap allows duplicate keys.
cpp#include <map> #include <string> #include <iostream> int main() { std::map<std::string, int> inventory; inventory["sword"] = 5; inventory["shield"] = 2; inventory["potion"] = 12; // Lookup: O(log n) if (inventory.find("sword") != inventory.end()) { std::cout << "Swords in stock: " << inventory["sword"] << "\n"; } // Iterates in sorted key order (alphabetical for strings) for (const auto& [item, count] : inventory) { std::cout << item << ": " << count << "\n"; } // Output: potion: 12, shield: 2, sword: 5 }
The sorted property comes with overhead. If you don't need order and just want fast lookups, reach for unordered containers instead.
Unordered containers use hash tables for average-case O(1) lookups, insertions, and deletions. The trade-off? Elements aren't sorted, and worst-case performance degrades to O(n) if your hash function produces collisions.
std::unordered_set and std::unordered_map are the workhorses here. Use them when you need fast membership tests or key-value lookups and don't care about order.
cpp#include <unordered_set> #include <iostream> int main() { std::unordered_set<int> seen = {3, 7, 11}; seen.insert(15); // O(1) average if (seen.count(7)) { // O(1) average lookup std::cout << "7 was seen\n"; } // Iteration order is undefined for (int val : seen) { std::cout << val << " "; } // Possible output: 15 11 3 7 (order unpredictable) }
Hash containers require your key type to be hashable. Standard types (integers, strings) work out of the box. For custom types, you'll need to provide a hash function and equality operator.
| Container | Lookup | Insert/Delete | Sorted? | Use When |
|---|---|---|---|---|
std::set / std::map | O(log n) | O(log n) | Yes | Need sorted iteration |
std::unordered_set / std::unordered_map | O(1) avg | O(1) avg | No | Need raw speed |
Container adaptors wrap sequence containers and restrict the interface to enforce specific behavior. They don't provide iterators—you interact through specialized methods.
std::stack is LIFO (last in, first out). You push() onto the top and pop() from the top. It's typically backed by std::deque but you can specify std::vector or std::list.
std::queue is FIFO (first in, first out). You push() at the back and pop() from the front. Backed by std::deque by default.
std::priority_queue is a heap. Elements are ordered by priority, and you always pop the highest-priority element. Backed by std::vector with heap operations.
cpp#include <stack> #include <queue> #include <iostream> int main() { std::stack<int> undo_stack; undo_stack.push(1); undo_stack.push(2); std::cout << "Top: " << undo_stack.top() << "\n"; // 2 undo_stack.pop(); std::cout << "Top: " << undo_stack.top() << "\n"; // 1 std::priority_queue<int> pq; pq.push(10); pq.push(30); pq.push(20); std::cout << "Highest: " << pq.top() << "\n"; // 30 }
Use adaptors when the semantics match your problem domain exactly. They signal intent and prevent misuse.
std::array is a fixed-size array wrapper with STL-style iterators. Size is a template parameter, so it's known at compile time. It lives on the stack (if declared locally) and has zero overhead compared to a raw array.
cpp#include <array> #include <algorithm> #include <iostream> int main() { std::array<int, 4> coords = {10, 20, 30, 40}; std::sort(coords.begin(), coords.end()); // Works with STL algorithms for (int c : coords) { std::cout << c << " "; } }
std::forward_list is a singly-linked list. It uses less memory per node than std::list (no backward pointer) but you can only traverse forward. Rarely used—only when memory per element matters critically.
std::string is technically a sequence container specialized for characters. It has small-string optimization (SSO): short strings live in the object itself without heap allocation.
| Need | Reach for |
|---|---|
| Default go-to container | std::vector |
| Fast lookup by key, no order needed | std::unordered_map / std::unordered_set |
| Fast lookup by key, need sorted iteration | std::map / std::set |
| Insert/remove at both ends | std::deque |
| LIFO operations only | std::stack |
| FIFO operations only | std::queue |
| Always pop highest priority | std::priority_queue |
| Fixed size known at compile time | std::array |
| Frequent mid-sequence splicing, stable iterators | std::list |
- Using
std::listby default: You think "linked list = fast inserts" but forget that finding where to insert is O(n). Cache misses murder real-world performance. Benchmark before choosingstd::list. - Ignoring reserve() for vectors: If you know roughly how many elements you'll store, call
reserve()upfront. Otherwise,std::vectorreallocates multiple times as it grows, copying everything each time. - Assuming
std::maplookup is O(1): It's O(log n). If you don't need sorted order,std::unordered_mapis almost always faster. - Not checking
find()before dereferencing: For associative containers,find()returns an iterator. If the key doesn't exist, you getend(). Dereferencingend()is undefined behavior—always check. - Forgetting iterator invalidation rules: Inserting into a
std::vectorcan reallocate, invalidating all iterators. Erasing from astd::maponly invalidates the erased iterator. Know the rules or face crashes. - Using raw
operator[]on maps for existence checks:map[key]inserts a default-constructed value if the key doesn't exist. Usefind()orcount()to check membership without side effects.
💡 Think Like a Programmer: The "best" container depends entirely on access patterns. Profile your code's hot paths—if you're iterating sequentially most of the time,
std::vectorwins. If lookups dominate, hash tables reign. Let data guide your choice, not folklore.
Keep Reading
Pointers in C: The Mental Model That Finally Makes Them Click
A pointer is a variable that stores a memory address instead of a regular value like a number or character. Think of it like this: your house number isn't your house—it's just a way to find your house.
JavaScript Closures Explained With Real Examples
A closure is a function bundled together with its surrounding state — the lexical environment in which it was created.
10 Python One-Liners Every Beginner Should Memorise
A Python one-liner is exactly what it sounds like: code that accomplishes a meaningful task in a single line.
