Tag Dispatching in C++: Overload Selection by Tag Types, vs if constexpr and Concepts

Key takeaways

Tag dispatching predates if constexpr and concepts as the standard way to pick a compile-time code path through ordinary overload resolution. This guide walks through the mechanics of how the compiler actually selects a tag-based overload, why iterator category tags form an inheritance hierarchy, where tag dispatch still beats if constexpr and concepts in modern C++, and a real failure mode where a mis-tagged iterator caused silent out-of-bounds pointer arithmetic instead of a compile error.

What is Tag Dispatching?

Tag dispatching uses empty tag types to drive function overload resolution at compile time, enabling type-specific optimizations without runtime branching.

The pattern exists because C++ needed a way to select between multiple implementations of the same logical operation—based purely on a type property known at compile time—long before the language had any first-class tool for that job. Before C++17’s if constexpr and before C++20’s concepts and requires clauses, the only mechanisms available for compile-time branching were template specialization, SFINAE, and plain overload resolution. SFINAE-based dispatch works, but it is notoriously unreadable: you end up burying the actual selection logic inside enable_if expressions in template parameter lists, and a single typo in a trait expression turns into a wall of substitution-failure diagnostics that bury the real error under pages of noise. Tag dispatch sidesteps that entirely by turning “pick an implementation based on a type property” into “pick an overload based on an ordinary function argument”—something the compiler was already extremely good at doing, using the same overload resolution rules that apply to any ordinary C++ function call.

// Tag types
struct IntTag {};
struct FloatTag {};
// Tag dispatch implementations
void processImpl(int value, IntTag) {
    cout << "Integer: " << value << endl;
}
void processImpl(double value, FloatTag) {
    cout << "Float: " << value << endl;
}
// Unified interface
template<typename T>
void process(T value) {
    if constexpr (is_integral_v<T>) {
        processImpl(value, IntTag{});
    } else {
        processImpl(value, FloatTag{});
    }
}
int main() {
    process(10);    // Integer
    process(3.14);  // Float
}

Notice that this particular example actually mixes two techniques: it uses if constexpr to pick which tag to construct, and then relies on ordinary overload resolution to pick which processImpl runs. That is a fair way to demonstrate the tag mechanism, but it is not how tag dispatch was used historically—in pre-C++17 code, the tag itself was produced by a trait (usually a using tag = ... alias inside a traits class), and the calling function had no branching statement at all. The whole point of classic tag dispatch is that the “if” disappears: there is no runtime or compile-time conditional anywhere in the calling code, only a single unconditional call whose overload is selected by the compiler based on the tag’s type. That is a meaningful difference in practice, because a if constexpr chain has to be edited every time you add a new case, whereas a purely overload-based dispatch lets you add a new overload for a new tag anywhere—including in a different header or a different translation unit—without touching the dispatcher at all. This is what people mean when they say tag dispatch is “open” while if constexpr is “closed”: the set of branches in an if constexpr chain is fixed at the point where you write it, but the set of overloads participating in tag dispatch can grow as long as they are visible via ordinary lookup (including ADL) at the call site.


Iterator Tag Dispatching

STL uses iterator category tags to select optimal algorithms. Random-access iterators can jump directly (it += n), while input iterators must increment one-by-one.

To understand why this works reliably, you have to look at how the iterator category tags are actually defined. They are not five independent, unrelated empty structs—they form an inheritance hierarchy that mirrors the conceptual hierarchy of iterator capabilities:

struct input_iterator_tag {};
struct output_iterator_tag {};
struct forward_iterator_tag : input_iterator_tag {};
struct bidirectional_iterator_tag : forward_iterator_tag {};
struct random_access_iterator_tag : bidirectional_iterator_tag {};
struct contiguous_iterator_tag : random_access_iterator_tag {}; // C++20

This hierarchy is not a cosmetic detail—it is what makes overload resolution behave sensibly when a library only bothers to provide two or three overloads instead of one per category. When you call advanceImpl(it, n, some_tag{}) and there happen to be overloads for input_iterator_tag and random_access_iterator_tag but nothing for bidirectional_iterator_tag, a bidirectional iterator’s tag object converts to input_iterator_tag via the derived-to-base conversion, and the compiler picks that overload because it is the only viable one. If overloads exist for both input_iterator_tag and forward_iterator_tag, and the actual tag is bidirectional_iterator_tag, overload resolution prefers the more derived base—forward_iterator_tag—because a derived-to-base conversion to a closer base class is considered a better conversion sequence than one to a more distant base. This is exactly the same rule the language uses for ordinary class hierarchies and virtual dispatch candidates; there is nothing iterator-specific about it. The STL authors leaned on plain C++ inheritance and standard overload resolution rules rather than inventing a bespoke dispatch mechanism, which is part of why the pattern generalizes so well to user code.

#include <iterator>
// Implementation functions
template<typename Iter>
void advanceImpl(Iter& it, int n, input_iterator_tag) {
    // Input iterator: one step at a time
    while (n--) ++it;
}
template<typename Iter>
void advanceImpl(Iter& it, int n, random_access_iterator_tag) {
    // Random access iterator: jump directly
    it += n;
}
// Unified interface
template<typename Iter>
void advance(Iter& it, int n) {
    advanceImpl(it, n, typename iterator_traits<Iter>::iterator_category{});
}
int main() {
    vector<int> v = {1, 2, 3, 4, 5};
    auto it = v.begin();
    advance(it, 3);  // Random access (fast)

    list<int> l = {1, 2, 3, 4, 5};
    auto it2 = l.begin();
    advance(it2, 3);  // Input iterator (slow)
}

vector’s iterator has iterator_category equal to random_access_iterator_tag, so the second overload of advanceImpl is an exact match and gets picked—no conversion needed. list’s iterator only offers bidirectional_iterator_tag. There is no overload for that tag in this minimal example, so the compiler falls back to converting it to input_iterator_tag (the only base class of bidirectional_iterator_tag for which an overload exists) and calls the slow, one-step-at-a-time version. This is correct behavior, but it is also exactly the kind of silent fallback that can bite you: if you meant to write a bidirectional-specific overload and simply forgot, you would get no error at all—just a slower path than you intended. The code compiles, runs, and produces the right answer; it is just not using the fast path you assumed it would use. I’ve seen this exact thing happen in a “sped up” std::list-based routine that quietly kept using the O(n) input-iterator path because nobody added the bidirectional_iterator_tag overload—the benchmark numbers looked wrong for weeks before someone thought to check which overload was actually being instantiated.


Practical Examples

Example 1: Distance Calculation

// Input iterator
template<typename Iter>
typename iterator_traits<Iter>::difference_type
distanceImpl(Iter first, Iter last, input_iterator_tag) {
    typename iterator_traits<Iter>::difference_type n = 0;
    while (first != last) {
        ++first;
        ++n;
    }
    return n;
}
// Random access iterator
template<typename Iter>
typename iterator_traits<Iter>::difference_type
distanceImpl(Iter first, Iter last, random_access_iterator_tag) {
    return last - first;  // O(1)
}
template<typename Iter>
auto distance(Iter first, Iter last) {
    return distanceImpl(first, last,
        typename iterator_traits<Iter>::iterator_category{});
}
int main() {
    vector<int> v = {1, 2, 3, 4, 5};
    cout << distance(v.begin(), v.end()) << endl;  // 5 (fast)

    list<int> l = {1, 2, 3, 4, 5};
    cout << distance(l.begin(), l.end()) << endl;  // 5 (slow)
}

This is a simplified version of how std::distance is actually implemented in every major standard library. The reason this matters in production code is subtle: last - first is only well-defined for random-access iterators because it relies on pointer-like arithmetic that a list or forward_list iterator simply does not support at the type level—there is no operator- for a bidirectional iterator, so the random-access overload would not even compile if it were instantiated with a list::iterator. Because C++ templates are only instantiated when used, and overload resolution only considers the overload that actually matches the tag, the input_iterator_tag overload’s body (which only uses ++ and !=) is the only one ever compiled for list::iterator. This “only compile what is actually selected” property is one of tag dispatch’s quiet strengths: unlike a naive if constexpr-free branch, you never risk trying to compile pointer-arithmetic code against an iterator that cannot support it, because the two implementations are entirely separate function templates and only the chosen one is ever instantiated for a given type.

Example 2: Type-Based Serialization

struct PrimitiveTag {};
struct ContainerTag {};
struct CustomTag {};
// Type traits
template<typename T>
struct SerializeTraits {
    using tag = CustomTag;
};
template<> struct SerializeTraits<int> { using tag = PrimitiveTag; };
template<> struct SerializeTraits<double> { using tag = PrimitiveTag; };
template<typename T> struct SerializeTraits<vector<T>> { using tag = ContainerTag; };
// Implementations
template<typename T>
string serializeImpl(const T& value, PrimitiveTag) {
    return to_string(value);
}
template<typename T>
string serializeImpl(const T& container, ContainerTag) {
    string result = "[";
    for (const auto& item : container) {
        result += serialize(item) + ",";
    }
    result += "]";
    return result;
}
template<typename T>
string serializeImpl(const T& value, CustomTag) {
    return value.toString();  // Call custom method
}
// Unified interface
template<typename T>
string serialize(const T& value) {
    return serializeImpl(value, typename SerializeTraits<T>::tag{});
}
int main() {
    cout << serialize(42) << endl;
    cout << serialize(vector<int>{1, 2, 3}) << endl;
}

This example is a good illustration of tag dispatch used as a customization point: the default case (CustomTag, falling through to calling value.toString()) means any type the library authors never anticipated still gets a reasonable behavior as long as it defines toString(). That is exactly the “open extensibility” trade-off mentioned in the FAQ below—a new user-defined type doesn’t need to modify SerializeTraits or serializeImpl at all if it is happy with the default CustomTag behavior, and if it wants primitive-style serialization instead, the author only needs to add one specialization of SerializeTraits in their own header. Compare this to an if constexpr chain: to add a new case you would need edit access to the function itself, which is impossible for types living in someone else’s header that you cannot modify. This is the same reason std::hash and std::less are structured as specializable templates rather than single functions with an internal if constexpr chain—the standard library needs an extension mechanism that works without modifying <functional>.

Example 3: Copy Optimization

struct TrivialTag {};
struct NonTrivialTag {};
template<typename T>
using CopyTag = conditional_t<
    is_trivially_copyable_v<T>,
    TrivialTag,
    NonTrivialTag
>;
// Trivial types (memcpy)
template<typename T>
void copyImpl(T* dest, const T* src, size_t n, TrivialTag) {
    memcpy(dest, src, n * sizeof(T));
}
// Non-trivial types (constructor)
template<typename T>
void copyImpl(T* dest, const T* src, size_t n, NonTrivialTag) {
    for (size_t i = 0; i < n; i++) {
        new (&dest[i]) T(src[i]);
    }
}
template<typename T>
void copy(T* dest, const T* src, size_t n) {
    copyImpl(dest, src, n, CopyTag<T>{});
}
int main() {
    int arr1[5] = {1, 2, 3, 4, 5};
    int arr2[5];
    copy(arr1, arr2, 5);  // memcpy (fast)

    string str1[3] = {"a", "b", "c"};
    string str2[3];
    copy(str1, str2, 3);  // Constructor (safe)
}

This pattern is essentially a hand-rolled version of what std::copy and std::uninitialized_copy do internally in most standard library implementations: they check is_trivially_copyable and drop to memmove/memcpy when it’s safe, because calling a placement-new constructor in a loop for a type like int is pure overhead the optimizer may or may not manage to eliminate. The risk with this specific pattern is not in the dispatch mechanism—conditional_t is a compile-time-only construct and there is no ambiguity here since the two tags are unrelated, non-hierarchical types—but in the condition you dispatch on. is_trivially_copyable_v<T> is the correct trait for “can I memcpy this safely,” but it is easy to reach for a weaker or unrelated trait (as the “Common Issues” section below shows) and get a memcpy path that technically compiles but corrupts objects with vtables, internal pointers, or invariants that a bitwise copy does not preserve.

Example 4: Algorithm Optimization

struct SmallSizeTag {};
struct LargeSizeTag {};
template<size_t N>
using SizeTag = conditional_t<(N < 10), SmallSizeTag, LargeSizeTag>;
// Small arrays: bubble sort
template<typename T, size_t N>
void sortImpl(T (&arr)[N], SmallSizeTag) {
    for (size_t i = 0; i < N; i++) {
        for (size_t j = i + 1; j < N; j++) {
            if (arr[j] < arr[i]) {
                swap(arr[i], arr[j]);
            }
        }
    }
}
// Large arrays: quick sort
template<typename T, size_t N>
void sortImpl(T (&arr)[N], LargeSizeTag) {
    sort(begin(arr), end(arr));
}
template<typename T, size_t N>
void sort(T (&arr)[N]) {
    sortImpl(arr, SizeTag<N>{});
}
int main() {
    int small[5] = {5, 2, 8, 1, 9};
    sort(small);  // Bubble sort

    int large[100];
    sort(large);  // Quick sort
}

This example dispatches on a size_t non-type template parameter rather than a type trait, which is a reasonable thing to do but worth flagging as a design smell if taken too literally: hard-coding “arrays under 10 elements use bubble sort” as a blanket rule ignores that modern std::sort (introsort, typically quicksort with a median-of-three pivot, falling back to heapsort, with a small-range insertion-sort cutoff already built in) already handles small ranges efficiently on its own. In real code you would rarely write your own bubble sort for this; the example is included here purely to show that the tag does not have to come from a type trait—conditional_t on any compile-time boolean expression, including one derived from a template’s own non-type parameters, works the same way.


Tag Dispatch, if constexpr, and Concepts: Choosing the Right Tool

// if constexpr (C++17)
template<typename T>
void process(T value) {
    if constexpr (is_integral_v<T>) {
        // Integer processing
    } else {
        // Float processing
    }
}
// Tag Dispatching (C++11)
template<typename T>
void process(T value) {
    processImpl(value, TypeTag<T>{});
}
// Concepts / requires-clause (C++20)
template<typename T>
    requires integral<T>
void process(T value) { /* integer processing */ }

template<typename T>
    requires floating_point<T>
void process(T value) { /* float processing */ }

These three approaches solve overlapping but not identical problems, and picking the wrong one is a common source of unnecessarily convoluted code in modern C++ codebases.

Compile time. if constexpr tends to compile fastest for a small, closed set of branches because the compiler only has to evaluate one boolean condition and discard the untaken branch’s tokens without ever forming candidate overload sets. Tag dispatch and concepts both go through overload resolution, which means the compiler has to build a candidate set and rank conversions—usually negligible for a handful of overloads, but it does add up in headers with dozens of tag-dispatched overloads compiled across hundreds of translation units, since overload resolution repeats per instantiation.

Error message quality. This is where concepts earn their reputation. A requires clause failure produces a message naming the exact constraint that failed (“the associated constraint integral<T> is not satisfied because T = std::string”). A failed tag dispatch, in contrast, produces “no matching function for call to processImpl” followed by a list of every candidate and why each one didn’t match—readable once you know the pattern, but genuinely hostile to newcomers. if constexpr sits in between: if you get the branch condition wrong you at least get an error inside a real function body pointing at a real statement, not a resolved-then-discarded overload set.

Header-only and separate-compilation constraints. All three of these are template-based, so none of them escape the “templates must be visible at the point of instantiation” rule—there’s no world where tag dispatch is header-only but concepts require a compiled library, contrary to a claim you sometimes see repeated. The real difference is about extension, not compilation model: tag dispatch’s extensibility comes from ordinary two-phase name lookup and ADL, so a downstream user can add a new overload for a new tag in their own header, and it participates in dispatch as long as it is visible via ADL or is added to the same namespace the dispatcher looks in. A requires-constrained overload set is extended the same way—by adding another constrained overload—but the constraint itself (the concept) is usually defined once and not meant to be extended per-type the way a trait specialization is. if constexpr cannot be extended by outside code at all, short of forking the function, because the branches are baked into a single function body that only the original author controls.

My own rule of thumb after maintaining code that uses all three: use if constexpr for closed, local policy decisions inside one function where you control every branch and don’t need outside code to add cases. Reach for concepts/requires when you want strong, readable compile errors and the constraint is a genuine semantic requirement (comparable, iterable, arithmetic) rather than an arbitrary category. Keep tag dispatch specifically for the cases where the “tag” already exists as part of a type hierarchy you don’t control—iterator categories being the textbook case—or where you explicitly want third parties to be able to add new overloads without your permission. If you’re starting a brand-new C++20 codebase with no legacy constraints, you will reach for concepts far more often than tag dispatch; but you cannot avoid understanding tag dispatch, because so much of the standard library (iterator_traits, allocator_traits, large chunks of <type_traits>-adjacent code) is built on it and will remain built on it for backward-compatibility reasons for a very long time.


Common Issues

Issue 1: Missing tag type

// Bad: no tags
template<typename Iter>
void advance(Iter& it, int n) {
    it += n;  // Doesn't work for all iterators
}
// Good: tag dispatch
template<typename Iter>
void advance(Iter& it, int n) {
    advanceImpl(it, n, typename iterator_traits<Iter>::iterator_category{});
}

Without the tag, the naive version compiles happily for vector and fails to compile at all for list, because list::iterator has no operator+=. That failure is at least loud. The more dangerous version of this mistake is writing generic code that happens to compile for every iterator you tested against locally—because you only tested with vector—and shipping it, only to have it silently misbehave (or fail to compile in a downstream user’s code) the first time someone passes a list or map iterator through it.

Issue 2: Wrong tag selection

// Bad: wrong condition
template<typename T>
using Tag = conditional_t<sizeof(T) == 4, SmallTag, LargeTag>;
// Good: meaningful condition
template<typename T>
using Tag = conditional_t<is_trivially_copyable_v<T>, TrivialTag, NonTrivialTag>;

sizeof(T) == 4 is a real bug waiting to happen: it will happily route any 4-byte type—including a 4-byte struct holding a raw pointer alias with a non-trivial destructor on a hypothetical platform, or more realistically, any small non-trivial class that happens to occupy 4 bytes—down the “small object” path meant for trivial types, based purely on incidental size rather than the property you actually care about. The fix is always to dispatch on the semantic property (copyability, triviality, a named capability) rather than an incidental one (size, alignment, being a pointer) that happens to correlate with it in the cases you tested.

Issue 3: Tag hierarchies that aren’t respected

This is the mistake that doesn’t show up in most tag dispatch tutorials, and it’s the one that actually costs people debugging time. The iterator tag hierarchy (input_iterator_tag → forward_iterator_tag → bidirectional_iterator_tag → random_access_iterator_tag) exists so that a partial set of overloads still resolves sensibly via derived-to-base conversion, as explained above. The moment you introduce your own tag hierarchy for a similar purpose, you have to get the inheritance direction right, or overload resolution will do something you don’t expect—either becoming ambiguous, or silently selecting a less capable overload than the type actually supports.

struct ReadTag {};
struct WriteTag : ReadTag {};       // WriteTag "is-a" ReadTag: can convert WriteTag -> ReadTag
struct ReadWriteTag : WriteTag {};  // ReadWriteTag "is-a" WriteTag "is-a" ReadTag

void handle(ReadTag)  { /* read-only path */ }
void handle(WriteTag) { /* write path */ }
// no overload for ReadWriteTag

void use() {
    handle(ReadWriteTag{});  // converts to WriteTag (closer base) -- fine here
}

That example resolves the way you’d hope, because the hierarchy is a straight line and the compiler always prefers the nearer base class. The real danger appears the moment the hierarchy stops being linear—for instance, if you add a second, unrelated tag that a type could also convert to via an implicit user-defined conversion, or if you mistakenly declare a new tag as inheriting from the wrong level of the chain (declaring your ReadWriteTag as inheriting directly from ReadTag, skipping WriteTag, would make a call with only a ReadTag and WriteTag overload ambiguous, because both are equally “one level of derived-to-base conversion away” and the compiler has no basis to prefer one over the other). This is precisely the class of bug the standard library avoids by keeping the iterator tag hierarchy a single straight chain with no branching—and it’s exactly the discipline you have to impose on yourself if you introduce a custom hierarchy for your own dispatch scheme.

The more common and more dangerous version of this mistake, though, isn’t in the tag hierarchy’s shape—it’s in whether the tag a type claims actually matches the operations that type supports.

I ran into this firsthand while writing a small ring-buffer container with a custom iterator years ago. The iterator supported ++, --, and equality comparison, which technically made it a bidirectional iterator—but I typed random_access_iterator_tag into its iterator_category typedef, half out of habit from copy-pasting a vector-like iterator skeleton and half because I assumed I’d “get to” adding operator+= and operator- later and forgot to come back to it. Everything still compiled. That’s the trap: nothing in the type system checks that a type claiming random_access_iterator_tag actually defines operator+=, operator-, or operator[]—the tag is just a marker type with no connection enforced by the compiler to the operations it’s supposed to promise. The first time I ran a generic algorithm (a hand-written binary_search-style routine that dispatched to a random-access fast path using it + mid) against that iterator, operator+ didn’t exist on my iterator type, so that specific call did fail to compile—but a different algorithm I’d written earlier used advance(it, n) dispatched through my own tag-based advanceImpl, and I had defined operator+= for it, just implemented incorrectly: it walked forward one node at a time internally but did no bounds checking because I’d copied the “just add n” logic from the real random-access case, assuming the underlying storage was contiguous when it was actually a linked structure with wraparound. The result wasn’t a compile error at all—it was it += n silently computing a raw pointer past the buffer’s actual allocated storage and reading garbage, because the fast-path arithmetic assumed contiguous, wraparound-free memory that the ring buffer’s actual layout didn’t provide. The lesson that stuck with me: a tag is a promise the type makes about itself, and the compiler will never check that the promise is true. If you’re hand-writing an iterator (or any tag-dispatched type) and you’re not one hundred percent sure it satisfies every operation implied by the category tag you’re declaring, declare the weaker, honest tag—bidirectional_iterator_tag, in my case—and accept the slower dispatch path. A slow-but-correct fallback beats a fast path built on operations that don’t actually hold.


FAQ

Q1: When should I use tag dispatching?

A:

  • Type-based optimization where you don’t control every call site
  • STL algorithm implementation (or code that must interoperate with STL-style categories)
  • Extending behavior for types you can’t modify, without editing a central dispatcher

Q2: if constexpr vs tag dispatching?

A: In C++17+, if constexpr is more concise for closed, local branching. Tag dispatching remains clearer—and is often required—when the set of cases needs to stay open to code you don’t own, such as third-party iterator types or user-defined trait specializations.

Q3: Performance difference?

A: Both are resolved at compile time, so there’s no runtime dispatch overhead in either approach. The performance risk isn’t in the dispatch mechanism itself—it’s in accidentally selecting the wrong overload (see Issue 3 above), which can silently degrade an algorithm from O(1) to O(n) without any error.

Q4: How do I define tag types?

A: Define them as empty structs with no data members. If you need a category hierarchy (like iterator tags), express it with public inheritance so derived-to-base conversions let partial overload sets still resolve sensibly.

Q5: Does STL use this?

A: Yes—iterator_category is the textbook example, and allocator_traits, pointer_traits, and several <memory> internals use the same pattern.

Q6: Learning resources for tag dispatching?

A:

  • “Effective STL” (Scott Meyers)
  • cppreference.com (<iterator>, iterator_traits, std::advance, std::distance)
  • Your standard library’s own <bits/stl_iterator_base_types.h> or equivalent—reading the real implementation is more instructive than any tutorial