C++ ADL (Argument-Dependent Lookup): Namespaces & Operators
Key takeaways
Argument-dependent lookup in C++: finding functions in associated namespaces, swap idiom, operator overloads, pitfalls, and disabling ADL.
What is ADL?
Also called Koenig lookup: when resolving a unqualified function call, the compiler also searches namespaces associated with the argument types.
Without ADL, every generic function that operates on user-defined types would need to know the type’s namespace by name, which defeats the point of writing generic code at all. Consider std::swap(a, b) inside a template — the template author has no idea what namespace T lives in, so they can’t write MyLib::swap(a, b) even if a faster, type-specific swap exists there. ADL is the mechanism that lets an unqualified swap(a, b) call find MyLib::swap automatically, purely by looking at the type of a and b, without the caller ever writing MyLib:: anywhere. This is also why operator overloading works at all in ordinary code: std::cout << p only compiles because ADL finds MyLib::operator<< in the namespace of p’s type — if the compiler only looked in the current scope and its enclosing namespaces, virtually every third-party operator overload would require explicit qualification, and idiomatic C++ as it’s normally written would be impossible.
The rule that actually drives ADL is which namespaces count as “associated” with a type: for a class type, that’s the namespace the class is declared in, plus the namespaces of any base classes; for a template specialization, it also includes the namespaces of the template arguments. Understanding that rule — not just “the compiler is smart about namespaces” — is what lets you predict when ADL will kick in and when it won’t, which the pitfalls section below covers concretely.
namespace MyLib {
struct Point {
int x, y;
};
void print(const Point& p) {
std::cout << "(" << p.x << ", " << p.y << ")" << std::endl;
}
}
int main() {
MyLib::Point p{10, 20};
// ADL finds MyLib::print
print(p); // no MyLib:: prefix needed
MyLib::print(p); // qualified also works
}
Which Namespaces ADL Searches
When the compiler sees an unqualified call like func(x), it builds a candidate set from two sources: ordinary unqualified lookup (the current scope and its enclosing scopes, following normal C++ scoping rules) and, separately, ADL over the associated namespaces of every argument’s type. Both candidate sets are merged, and then normal overload resolution picks the best match from the combined set — ADL doesn’t bypass overload resolution, it just widens where candidates are allowed to come from. This distinction matters in the example below: func(x) finds A::func through ADL because x is an A::X, even though nothing named func is visible via ordinary lookup at the call site inside main.
namespace A {
struct X {};
void func(X) {
std::cout << "A::func" << std::endl;
}
}
namespace B {
void func(A::X) {
std::cout << "B::func" << std::endl;
}
}
int main() {
A::X x;
func(x); // ADL: A::func
B::func(x); // qualified: B::func
}
std::cout and ADL
This is the example that makes ADL feel less like a corner case and more like something you rely on every day without noticing. operator<< is declared inside namespace MyLib because it operates on MyLib::Point, and best practice is to keep overloads next to the type they extend rather than dumping them in the global namespace. Without ADL, std::cout << p would have no way to find MyLib::operator<< — you’d have to write MyLib::operator<<(std::cout, p) << std::endl, which is exactly the kind of syntax operator overloading exists to avoid. ADL closes that gap: because p’s type is MyLib::Point, the compiler looks inside MyLib when resolving the unqualified <<, finds the overload, and the natural os << p syntax just works.
#include <iostream>
namespace MyLib {
struct Point {
int x, y;
};
std::ostream& operator<<(std::ostream& os, const Point& p) {
return os << "(" << p.x << ", " << p.y << ")";
}
}
int main() {
MyLib::Point p{10, 20};
// ADL finds MyLib::operator<<
std::cout << p << std::endl;
}
ADL in Operators, swap, Comparisons, and Serialization
The examples below cover the four places ADL shows up most often in real codebases: arithmetic and stream operators, the two-step swap idiom, comparison operators used with standard algorithms, and chained stream-like operators in custom serialization code.
Operators
namespace Math {
struct Vector2 {
float x, y;
};
Vector2 operator+(const Vector2& a, const Vector2& b) {
return {a.x + b.x, a.y + b.y};
}
Vector2 operator-(const Vector2& a, const Vector2& b) {
return {a.x - b.x, a.y - b.y};
}
Vector2 operator*(const Vector2& v, float scalar) {
return {v.x * scalar, v.y * scalar};
}
std::ostream& operator<<(std::ostream& os, const Vector2& v) {
return os << "Vector2(" << v.x << ", " << v.y << ")";
}
}
int main() {
Math::Vector2 v1{1.0f, 2.0f};
Math::Vector2 v2{3.0f, 4.0f};
auto v3 = v1 + v2;
auto v4 = v1 - v2;
auto v5 = v1 * 2.0f;
std::cout << v3 << std::endl;
std::cout << v4 << std::endl;
std::cout << v5 << std::endl;
}
swap
This is the single most important ADL idiom in the language, and it’s worth understanding precisely because getting it wrong silently degrades performance rather than causing a compile error. std::swap has a generic implementation that works for any movable type via three move operations, but that generic version is often far more expensive than a type-specific one — swapping BigObject via generic std::swap would move-construct a temporary and perform two more moves, whereas MyLib::swap just swaps the internal std::vector’s pointers directly, which is O(1) regardless of data’s size. The line using std::swap; followed by an unqualified swap(obj1, obj2) is the “two-step” idiom: it brings std::swap into scope as a fallback candidate without qualifying the call, which leaves the door open for ADL to also search MyLib and find the more efficient overload. If you instead wrote std::swap(obj1, obj2) directly, the explicit qualification disables ADL entirely and you’d always pay for the generic, slower version — even though a faster one exists right next to the type.
namespace MyLib {
struct BigObject {
std::vector<int> data;
BigObject(size_t size) : data(size) {}
};
void swap(BigObject& a, BigObject& b) noexcept {
a.data.swap(b.data);
std::cout << "MyLib::swap" << std::endl;
}
}
int main() {
MyLib::BigObject obj1(1000);
MyLib::BigObject obj2(2000);
using std::swap; // fallback
swap(obj1, obj2); // ADL may pick MyLib::swap
}
Comparisons
std::sort calls operator< on the elements it’s given using an unqualified expression internally, precisely so that it can work with any type that defines < in its own namespace without the standard library needing to know that namespace in advance. This is what makes std::sort(records.begin(), records.end()) compile at all for Data::Record — the standard library header has no using namespace Data, and never could, since it’s compiled long before Data exists. ADL is the only reason a generic algorithm can call an operator defined in code the algorithm’s author never saw.
namespace Data {
struct Record {
int id;
std::string name;
};
bool operator==(const Record& a, const Record& b) {
return a.id == b.id;
}
bool operator!=(const Record& a, const Record& b) {
return !(a == b);
}
bool operator<(const Record& a, const Record& b) {
return a.id < b.id;
}
}
int main() {
Data::Record r1{1, "Alice"};
Data::Record r2{2, "Bob"};
if (r1 == r2) {
std::cout << "equal" << std::endl;
}
if (r1 < r2) {
std::cout << "r1 less" << std::endl;
}
std::vector<Data::Record> records = {r2, r1};
std::sort(records.begin(), records.end());
}
Serialization
The line s << points[i]; inside the vector overload of operator<< is where this pattern earns its keep: it’s an unqualified call inside the same namespace as both overloads, so it looks like it could resolve either via ordinary namespace-member lookup or ADL — but the distinction matters once this code gets reused from outside Serialization. If a caller in a different namespace writes a generic function that also calls s << item on some Serializer, that call only finds Serialization::operator<< because ADL associates s’s type (Serialization::Serializer) with the Serialization namespace. Design custom stream-like types with this in mind: keep every overload that should participate in chained << calls inside the same namespace as the type itself, and ADL keeps the chain working from any call site.
namespace Serialization {
struct Serializer {
std::ostringstream stream;
std::string str() const {
return stream.str();
}
};
struct Point {
int x, y;
};
Serializer& operator<<(Serializer& s, const Point& p) {
s.stream << "{x:" << p.x << ",y:" << p.y << "}";
return s;
}
Serializer& operator<<(Serializer& s, const std::vector<Point>& points) {
s.stream << "[";
for (size_t i = 0; i < points.size(); i++) {
if (i > 0) s.stream << ",";
s << points[i]; // ADL
}
s.stream << "]";
return s;
}
}
int main() {
Serialization::Serializer s;
Serialization::Point p1{10, 20};
Serialization::Point p2{30, 40};
s << p1;
std::cout << s.str() << std::endl;
std::vector<Serialization::Point> points = {p1, p2};
Serialization::Serializer s2;
s2 << points;
std::cout << s2.str() << std::endl;
}
Lookup scope notes
It’s tempting to assume ADL searches “up” through enclosing namespaces the way ordinary name lookup does, but it doesn’t — it only searches namespaces associated with the argument’s type, regardless of where the call site happens to be. In the example below, C::test() calling func(x) fails even though C and B might feel related in a codebase’s mental model, because x’s type is A::X, and ADL only ever looks in A (and any namespaces associated with A::X’s base classes, if it had any) — never in C, and never in B unless B is somehow associated with the type itself. This is a common source of “but it’s right there” confusion: proximity in the source file has no bearing on ADL, only the type of the argument does.
namespace A {
struct X {};
}
namespace B {
void func(A::X) {
std::cout << "B::func" << std::endl;
}
}
namespace C {
void test() {
A::X x;
// func(x); // error: not found in C
// ADL searches namespaces associated with argument types (here A), not C
}
}
Nested namespaces
Nesting doesn’t change the rule, it just changes which namespace gets associated: a type declared inside Outer::Inner associates its innermost enclosing namespace, Outer::Inner, not Outer alone. This is worth stating explicitly because it’s easy to assume ADL would also check Outer “for good measure” — it won’t, unless a function is actually declared there. If you split a library’s types and their free functions across nested namespaces inconsistently (a type in Outer::Inner but its operator overload accidentally left in Outer), ADL simply won’t find it, and the failure looks identical to any other “function not found” error rather than hinting at the namespace mismatch.
namespace Outer {
namespace Inner {
struct X {};
void func(X) {
std::cout << "Inner::func" << std::endl;
}
}
}
int main() {
Outer::Inner::X x;
func(x); // ADL: Outer::Inner::func
}
Surprising Overloads and Other ADL Traps
Each pitfall below is a variation on the same theme: ADL is a purely mechanical rule about types and namespaces, with no awareness of what the programmer “meant,” so it happily produces results that are technically correct but locally surprising.
Unexpected overload
When both a global process and a namespace-scoped MyLib::process exist for the same argument type, an unqualified process(d) prefers the ADL-found MyLib::process over the global one — ADL candidates and ordinary-lookup candidates are merged and ranked by normal overload resolution, and here they’re equally viable, but namespace-scoped functions found via ADL are not automatically deprioritized relative to the global namespace. If a codebase has grown a global-namespace helper of the same name as a library-internal one (easy to happen in a large project with multiple contributors), calls that look identical can silently dispatch to different functions depending only on which namespaces happen to contain a matching overload. ::process(d) is the escape hatch — explicit global qualification always wins because it bypasses lookup entirely.
namespace MyLib {
struct Data {};
void process(Data) {
std::cout << "MyLib::process" << std::endl;
}
}
void process(MyLib::Data) {
std::cout << "global::process" << std::endl;
}
int main() {
MyLib::Data d;
process(d); // ADL: MyLib::process
::process(d); // global::process
}
Templates
This is precisely the mechanism that makes generic code able to call type-specific overloads it has no compile-time knowledge of, and it’s also the reason “customization point” designs in modern C++ (like the pattern behind std::ranges or std::begin) lean so heavily on ADL. Inside call, the unqualified func(value) doesn’t know at the point of definition what T will be — it’s resolved via ADL at the point of instantiation, once T is known, against whatever namespace T actually belongs to. This is exactly why using std::swap; swap(a, b); works as a customization point inside generic template code: the template author writes one unqualified call, and every caller’s own namespace-specific overload gets picked up automatically, without the template ever being modified.
namespace MyLib {
struct X {};
void func(X) {
std::cout << "MyLib::func" << std::endl;
}
}
template<typename T>
void call(T value) {
func(value); // ADL applies here
}
int main() {
MyLib::X x;
call(x); // MyLib::func
}
using declarations
using B::func; brings B::func into the current scope as an ordinary-lookup candidate, on equal footing with whatever ADL finds. When func(x) is then called with an A::X argument, both B::func (visible via the using declaration) and A::func (found via ADL, since x’s type lives in A) are viable candidates with identical signatures after argument conversion, and the call is genuinely ambiguous — the compiler has no tiebreaker because a using-declared function and an ADL-found function are just two candidates in the same overload set. This is a real hazard when a file has several using SomeNamespace::func; declarations near the top for convenience: each one is a latent ambiguity waiting for an argument whose own ADL-associated namespace also happens to declare a func.
namespace A {
struct X {};
void func(X) { std::cout << "A::func" << std::endl; }
}
namespace B {
void func(A::X) { std::cout << "B::func" << std::endl; }
}
int main() {
using B::func;
A::X x;
func(x); // ambiguous: A::func vs B::func
}
Built-in types
Fundamental types (int, double, pointers, and so on) have no namespace of their own, so there’s nothing for ADL to search — an unqualified call with only built-in-typed arguments resolves purely through ordinary lookup, which here finds the global func(int) because it’s the only candidate ordinary lookup can see from inside main. This is a useful mental shortcut: if every argument to a call is a built-in type, ADL simply doesn’t apply, no matter how many namespace-scoped overloads exist elsewhere — you always need explicit qualification (MyLib::func(x)) to reach them.
void func(int) {
std::cout << "func(int)" << std::endl;
}
namespace MyLib {
void func(int) {
std::cout << "MyLib::func(int)" << std::endl;
}
}
int main() {
int x = 10;
func(x); // global::func (no ADL for int)
MyLib::func(x); // qualified
}
Disabling ADL (mostly)
There’s no language keyword to turn ADL off; instead, the two idioms below work by changing the form of the call so that name lookup happens differently under the hood. Wrapping the callee in parentheses, (func)(x), is the more obscure of the two: parenthesizing an expression that names a function suppresses ADL specifically because the parenthesized form is no longer a plain unqualified-id in the grammar sense that ADL’s rule applies to — the standard carves out this exact case. ::func(x), global qualification, is more common in practice and easier to reason about: any qualified name lookup (whether with ::, a namespace name, or a class name) bypasses ADL entirely, because ADL is specifically a rule for unqualified calls. In real code, reach for :: qualification when you need to be certain which overload runs; the parenthesized form is worth recognizing when you encounter it in someone else’s code, but rarely worth writing yourself since it reads as a typo to most reviewers.
namespace MyLib {
struct X {};
void func(X) { std::cout << "MyLib::func" << std::endl; }
}
void func(MyLib::X) {
std::cout << "global::func" << std::endl;
}
int main() {
MyLib::X x;
func(x); // ADL: MyLib::func
(func)(x); // no ADL: global::func
::func(x); // global::func
}
Keep Operators and swap in the Type’s Namespace
The pattern below summarizes the practical rule that falls out of everything above: define a type’s operators and swap function in the same namespace as the type, never in the global namespace, and let ADL do the work of making them discoverable from unqualified call sites. This isn’t just style — it’s what makes the type composable with generic code (the std::sort/operator< case), with the swap idiom, and with std::cout-style chaining, all without any caller needing to know or write the namespace name. The one line of commentary at the bottom is the practical takeaway: global-namespace operator overloads for library types are a common source of the ambiguity and unexpected-overload pitfalls covered above, because they compete with ADL-found candidates on equal footing instead of only being found when the type’s own namespace is searched.
namespace MyLib {
struct Point {
int x, y;
};
Point operator+(const Point& a, const Point& b) {
return {a.x + b.x, a.y + b.y};
}
void swap(Point& a, Point& b) noexcept {
std::swap(a.x, b.x);
std::swap(a.y, b.y);
}
std::ostream& operator<<(std::ostream& os, const Point& p) {
return os << "(" << p.x << ", " << p.y << ")";
}
}
// Avoid dumping operators in global namespace for library types
FAQ
Q1: When does ADL apply?
A:
- Unqualified calls
- Associated namespaces of argument types
- Especially operators
Q2: Why does it exist?
A:
- Less verbose than always qualifying
- Natural operator syntax
- Cleaner generic code
Q3: How to disable?
A:
(func)(x)::func(x)
Q4: Built-in types?
A: No associated namespace—no ADL.
Q5: Pitfalls?
A:
- Surprise overload resolution
- Name clashes—qualify when needed