ODR Violations: When Two Definitions Disagree
The One Definition Rule explained: what it covers, how violations cause silent UB, how to detect them with LTO and sanitizers, and how C++17 inline variables fix the problem.
The One Definition Rule (ODR) is C++'s contract that each entity has exactly one definition across all translation units. Violating it produces undefined behavior with no diagnostic required β the program may silently compute wrong results, corrupt memory, or crash in ways that have nothing to do with the bug site.
What the ODR covers
The ODR has two tiers:
Tier 1 β exactly one definition per program:
- Non-inline functions
- Non-inline variables
main
// foo.cpp
int counter = 0; // definition
// bar.cpp
int counter = 0; // ODR violation: two definitions of the same variable
// Linker will typically catch this β but not alwaysTier 2 β multiple definitions allowed, but they must be identical:
- Inline functions and inline variables
- Classes, structs, unions, enums
- Templates
constexprfunctions and variables
// widget.hpp included in multiple TUs β this is allowed:
class Widget {
int id_;
public:
explicit Widget(int id) : id_(id) {}
int id() const { return id_; }
};Every TU that includes widget.hpp gets its own definition of Widget. ODR requires these definitions to be token-for-token identical (modulo name lookup and overload resolution). If they differ, it is ODR UB β no diagnostic required.
How violations happen in practice
1. Different #define values in different TUs
// config_a.cpp
#define WIDGET_SIZE 4
#include "widget.hpp" // Widget compiled with size=4 layout
// config_b.cpp
#define WIDGET_SIZE 8
#include "widget.hpp" // Widget compiled with size=8 layout β ODR violation!The two TUs now have conflicting definitions of Widget. The linker picks one arbitrarily. Code compiled against one definition will silently misread objects compiled against the other.
2. Conditional compilation diverges definitions
// widget.hpp
class Widget {
#ifdef ENABLE_LOGGING
Logger logger_; // 32 bytes extra β only in some TUs
#endif
int id_;
};If some TUs compile with ENABLE_LOGGING and others don't, sizeof(Widget) differs between TUs. An object allocated and written by a logging TU will have its id_ at a different offset than what a non-logging TU expects. Result: silent data corruption.
3. Inline function bodies differ
// version.hpp (included by many TUs)
inline int api_version() { return 1; }
// version_new.hpp (included in one TU only)
inline int api_version() { return 2; }Both headers are included in the same program. The linker may use either definition. All callers get the same body β but which one is a coin flip.
4. Template instantiation differences
// file_a.cpp β includes specialization header
#include "fast_hash.hpp" // specializes std::hash<MyKey> one way
std::unordered_map<MyKey, int> map_a;
// file_b.cpp β doesn't include the specialization
std::unordered_map<MyKey, int> map_b; // uses default std::hash<MyKey>Two std::unordered_map instantiations with logically different hash functions but the same type. The ODR merges them to one definition; callers may use the wrong one.
Why the linker doesn't always catch it
The traditional (non-LTO) link model sees object files as bags of symbols. It picks one definition of each multiply-defined symbol and discards the rest. It has no visibility into whether the discarded definitions were semantically equivalent.
Tier 2 violations (class/template/inline mismatches) are particularly invisible because the linker expects to see multiple definitions of inline/template entities β that is normal β and has no way to compare their contents without cross-TU analysis.
Detection tools
gold --detect-odr-violations
The gold linker (GNU binutils) has an ODR checker:
# Link with gold and enable ODR detection:
g++ -fuse-ld=gold -Wl,--detect-odr-violations my_objs...
# Output example:
# warning: obj/widget.o: type mismatch for symbol Widget
# obj/widget.o: struct Widget { int id_; } [4 bytes]
# obj/logger.o: struct Widget { Logger logger_; int id_; } [36 bytes]This does not catch all violations β only those where DWARF debug info shows conflicting type information. Build with -g for useful output.
GCC LTO: -Wodr and -Wlto-type-mismatch
Link-Time Optimization forces the compiler to re-analyze all TUs together, which makes ODR violations visible:
# Compile all TUs with LTO:
g++ -flto -Wodr -Wlto-type-mismatch -g -c widget.cpp
g++ -flto -Wodr -Wlto-type-mismatch -g -c logger.cpp
# Link (LTO happens here):
g++ -flto -Wodr -Wlto-type-mismatch widget.o logger.o -o app
# Warning output:
# warning: type 'Widget' violates the C++ One Definition Rule [-Wodr]
# widget.cpp:3: a type with the same name but different fields
# logger.cpp:3: a type with the same name but different fields-Wlto-type-mismatch fires on type mismatches that cross TU boundaries even when the names don't match exactly.
AddressSanitizer ODR detection
ASan can detect ODR violations at runtime via two mechanisms:
# Method 1: shadow memory poisoning (default)
# ASan poisons the memory of globals that would be multiply-defined
# Any access to the shadow of a non-surviving definition is flagged
g++ -fsanitize=address -g my_code.cpp
# Method 2: explicit ODR indicator (stronger, more false-positives)
ASAN_OPTIONS=detect_odr_violation=2 ./my_app
# detect_odr_violation=1 : only flag globals with different sizes
# detect_odr_violation=2 : flag any multiply-defined global
# ASan output:
# ==ASAN== ERROR: AddressSanitizer: odr-violation
# [global] 'counter' defined in file_a.cpp at offset 0
# [global] 'counter' defined in file_b.cpp at offset 0 (size differs: 4 vs 8)adobe/orc
orc (ODR Race Condition checker) is a post-link tool from Adobe that parses DWARF info from a linked binary and reports conflicting type definitions:
# Install and run on a built binary:
orc --binary my_app
# Output:
# odr violation: struct Config
# in obj/main.o: { int timeout; bool debug; } (size 8)
# in obj/plugin.o: { int timeout; int retries; bool debug; } (size 12)Unlike -Wodr, orc works on pre-built binaries and integrates easily into CI post-build steps.
Clang -fsanitize=cfi (Control Flow Integrity)
CFI enforcement catches virtual dispatch into objects whose type the compiler can prove is inconsistent β which often overlaps with ODR violations in vtable layout:
clang++ -fsanitize=cfi -flto -fvisibility=hidden my_code.cppCFI requires LTO and hidden visibility. It won't catch all ODR violations, but it catches the dangerous virtual-dispatch cases.
C++17 inline variables: the permanent fix for header-defined globals
The classic C++ pre-C++17 pattern of declaring a global in a header leads to ODR violations:
// constants.hpp β WRONG (pre-C++17): each TU gets its own definition
const int MAX_CONNECTIONS = 100; // internal linkage β OK for const
extern int global_count; // declaration only β OK
int global_count = 0; // definition β NEVER put in a header!C++17 inline variables allow a variable to have multiple definitions (one per TU) while the linker merges them to exactly one object:
// constants.hpp β CORRECT (C++17):
inline constexpr int MAX_CONNECTIONS = 100; // one address, all TUs
inline int global_count = 0; // one object, all TUs agreeinline tells the compiler and linker: "multiple definitions are expected; pick one and discard the rest." The surviving definition is guaranteed to be the same one in all TUs.
This also applies to static data members of class templates, which previously required a separate .cpp definition:
// C++14 β needed a .cpp for static member:
// widget.hpp: static int instance_count_;
// widget.cpp: int Widget::instance_count_ = 0;
// C++17 β define in the header:
class Widget {
inline static int instance_count_ = 0; // no .cpp needed
};ODR and templates
Template specializations are a common ODR trap. Explicit specializations declared in one header but defined in another can lead to some TUs seeing the primary template and others seeing the specialization:
// hash_key.hpp β primary template
template<typename T> struct Hasher { size_t operator()(T) const; };
// fast_hasher.hpp β explicit specialization for MyKey
template<> struct Hasher<MyKey> { size_t operator()(MyKey) const; };If fast_hasher.hpp is included in only some TUs, Hasher<MyKey> has two definitions: the primary template instantiation and the explicit specialization. The linker arbitrarily picks one. Rules:
- Explicit specializations must be declared (not just defined) before any TU uses the primary template for that type
- Put the explicit specialization declaration in the same header as the primary template
Practical workflow
1. Build with -g (DWARF) and -flto -Wodr for development builds
2. Run orc or gold --detect-odr-violations in CI post-link step
3. Add ASAN_OPTIONS=detect_odr_violation=2 to sanitizer test runs
4. Replace header-defined non-inline globals with inline variables (C++17)
5. Ensure all explicit template specializations are visible to all TUs
6. Avoid conditional #define that changes class layout (wrap in subclass or pimpl instead)Quick reference: ODR rules
| Entity | Allowed definitions | What "same" means |
|---|---|---|
| Non-inline function | Exactly one in the program | N/A |
| Non-inline variable | Exactly one in the program | N/A |
| Class/struct/union | One per TU; all must match | Token-for-token identical |
| Inline function | One per TU; all must match | Same tokens, same semantics |
| Inline variable (C++17) | One per TU; linker merges | One object in final binary |
| Template (primary) | One per TU; all must match | Token-for-token identical |
| Explicit specialization | Exactly one in the program | N/A |
ODR violations detectable at runtime
ASan's ODR detector catches global-variable violations that slip past the linker. When -fsanitize=address is active, the runtime maintains shadow metadata for every global and can identify cases where two definitions of the same symbol were present at link time with different sizes or contents.
# Compile all TUs with ASan and DWARF:
g++ -fsanitize=address -g -c file_a.cpp -o file_a.o
g++ -fsanitize=address -g -c file_b.cpp -o file_b.o
g++ -fsanitize=address file_a.o file_b.o -o app
# Run with ODR checking enabled:
ASAN_OPTIONS=detect_odr_violation=2 ./appdetect_odr_violation=1 fires only when the conflicting definitions have different sizes. detect_odr_violation=2 fires whenever the same symbol name appears in more than one object with any difference, including ordering of fields. Set it to 2 on nightly CI runs; it produces false positives for deliberately duplicated weak symbols (e.g., default implementations), which you can suppress via an ASan suppression file:
# asan.supp β suppress known-harmless duplicate weak symbols
odr_violation:libfoo.so:default_handler
ASAN_OPTIONS=detect_odr_violation=2:suppressions=asan.supp ./appThe gold linker's --detect-odr-violations flag is the compile-time complement: it parses DWARF type information from object files at link time and warns when the same class name resolves to structurally different types in different objects. It requires -g to be effective:
g++ -fuse-ld=gold -Wl,--detect-odr-violations -g file_a.o file_b.o -o app
# warning: obj/file_a.o: type mismatch for symbol 'Config'
# obj/file_a.o: struct Config { int timeout; bool debug; } [8 bytes]
# obj/file_b.o: struct Config { int timeout; int retries; } [8 bytes]Gold's checker is heuristic: it only inspects symbols that appear in multiple objects and have DWARF records. It will miss violations in template instantiations that are folded by the linker before DWARF is consulted.
inline variables β the C++17 fix for header-defined globals
inline variables (C++17) give header-defined globals exactly one address across all translation units without any extern/.cpp dance. The token inline on a variable at namespace scope means: "multiple definitions are permitted; the linker must merge them to one object." This is the variable analogue of the long-standing inline function rule.
Before C++17 the idiom for a shared counter in a header required splitting declaration and definition:
// pre-C++17 β must split across files
// counter.hpp
extern int g_request_count;
// counter.cpp
int g_request_count = 0;With inline variables the header is self-contained:
// counter.hpp β C++17, no .cpp needed
inline int g_request_count = 0;
// Every TU that includes this header gets its own definition at compile time.
// The linker selects one (the first it encounters) and discards the rest.
// All TUs share the same address β &g_request_count is identical everywhere.inline constexpr is the canonical way to define compile-time constants with a guaranteed address (useful when passing them by reference or taking their address in templates):
// sizes.hpp
inline constexpr std::size_t kCacheLineSize = 64;
inline constexpr std::size_t kPageSize = 4096;
// Takes a reference β needs a real address, not just a compile-time value
void set_alignment(const std::size_t& align);
set_alignment(kCacheLineSize); // OK: kCacheLineSize has one addressStatic data members of class templates historically required explicit instantiation in exactly one .cpp. inline removes that requirement:
template<typename T>
class Registry {
public:
inline static std::vector<T*> instances; // C++17: defined here, no .cpp
};
// All specializations β Registry<Foo>, Registry<Bar> β have their own
// independent `instances` vector, each defined exactly once.ODR and templates: instantiation identity across TUs
Every instantiation of a template with the same arguments must produce a token-for-token identical definition in every translation unit that instantiates it; differing optimization flags or macro state can create silent violations. The standard says that two implicit instantiations in different TUs are the "same definition" as long as they use the same source tokens β but if macros alter those tokens, the rule is broken.
The most common real-world trap is optimization-dependent inlining changing what the compiler emits for a template function body. With LTO this becomes visible:
// math_utils.hpp
template<typename T>
T fast_sqrt(T x) {
#ifdef FAST_MATH
return __builtin_sqrtf(x); // different token sequence when FAST_MATH defined
#else
return std::sqrt(x);
#endif
}If file_a.cpp is compiled with -DFAST_MATH and file_b.cpp is compiled without it, you have two conflicting definitions of fast_sqrt<float>. Without LTO the linker silently picks one; with LTO, -Wlto-type-mismatch fires:
warning: function 'fast_sqrt<float>' has different body in different translation units
file_a.cpp:4: defined here (uses __builtin_sqrtf)
file_b.cpp:5: defined here (uses std::sqrt)The fix is to enforce a single set of preprocessor definitions across all TUs, or to use if constexpr and template parameters instead of macros:
template<typename T, bool FastMath = false>
T fast_sqrt(T x) {
if constexpr (FastMath)
return __builtin_sqrtf(x);
else
return std::sqrt(x);
}Explicit specializations are even more dangerous: if you declare a specialization in one header but define it in a .cpp that not all TUs include, any TU that does not see the definition will instantiate the primary template instead β two conflicting definitions for the same arguments, UB with no diagnostic:
// hash_ext.hpp (forward-declares the specialization)
template<> struct std::hash<MyKey>; // declaration only
// hash_ext.cpp (the definition β but only linked in, not seen by all TUs)
template<> struct std::hash<MyKey> { size_t operator()(MyKey k) const { ... } };Rule: always put the definition of an explicit specialization in the header that declares the primary template, or in a separate header that every TU using MyKey must include.
LTO and ODR: link-time optimization as a diagnostic layer
Link-time optimization forces the compiler to view all translation units simultaneously, turning many silent ODR violations into hard linker errors or -Wodr warnings. LTO is the single most effective ODR detection technique available in production toolchains.
With -flto, GCC and Clang serialize IR (Intermediate Representation) into object files instead of machine code. At link time the combined IR is re-optimized globally. This exposes:
- Type mismatches β the linker sees all class layouts and flags differences
- Duplicate function bodies β inlining decisions are made globally, so conflicting definitions of the same function must be resolved
- Dead code elimination β the linker can remove definitions that are provably unreachable, eliminating some sources of ODR noise
Enable ODR diagnostics with LTO:
# Full fat-LTO build (strongest checking, slowest compile):
cmake -DCMAKE_CXX_FLAGS="-flto -Wodr -Wlto-type-mismatch -g" \
-DCMAKE_EXE_LINKER_FLAGS="-flto" ..
# Thin LTO (faster, still catches most violations):
cmake -DCMAKE_CXX_FLAGS="-flto=thin" \
-DCMAKE_EXE_LINKER_FLAGS="-flto=thin" ..LTO can also silently fix some violations: when two definitions are structurally identical but compiled as separate symbols, LTO's identical-code-folding (ICF) merges them into one, turning UB into correct behaviour. This is dangerous because it hides real problems β enabling LTO in production without first running -Wodr in CI can mask violations that re-emerge when LTO is disabled.
MSVC's equivalent is LTCG (Link-Time Code Generation):
# CMake β enable LTCG for Release builds on MSVC:
set_property(TARGET my_target PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)With LTCG and /GL (whole-program optimization), MSVC performs similar type-layout cross-checking. The diagnostics are less verbose than GCC's -Wodr but still catch size mismatches.
ODR and shared libraries: weak symbols and COMDAT
ODR rules apply within a shared library boundary, but across library boundaries the platform determines what happens β and the answers differ sharply between Linux (ELF weak symbols) and Windows (COMDAT sections). Understanding this distinction is critical when a type or inline function is defined in a header included by both a shared library and its caller.
Linux: ELF and weak symbols
On Linux, the dynamic linker uses symbol interposition: the first definition of a symbol found in the load order wins. Inline functions and template instantiations compiled into .so files are typically emitted as STB_WEAK symbols. If both the main executable and a .so define inline void log_event(Event), the dynamic linker keeps the first one it encounters and discards the rest β which is usually the intended behaviour, but depends on load order:
# Inspect symbol binding in a shared library:
nm -D libplugin.so | grep log_event
# 00001234 W _ZN8LogEvent9log_eventEv β W = weak bindingThe danger arises when two .so files are compiled with different versions of the same header and the ODR-merged inline function has different semantics. AddressSanitizer's detect_odr_violation=2 will catch this at runtime if both libraries are loaded into the same process.
To avoid accidental interposition, export only the ABI surface with explicit visibility:
// Mark everything hidden by default; export only what clients need:
#pragma GCC visibility push(hidden)
#include "internal_headers.hpp"
#pragma GCC visibility pop
// Public API with explicit default visibility:
__attribute__((visibility("default")))
void public_function();Windows: COMDAT and /OPT:ICF
On Windows, inline functions and template instantiations are placed in COMDAT sections. The linker's /OPT:ICF (Identical COMDAT Folding) flag merges sections with identical content. Unlike ELF weak symbols, COMDAT is resolved at static link time β there is no dynamic interposition.
If a function appears in a COMDAT section in both a .lib (static) and a .dll, and the .dll is linked into the same process, MSVC's linker will use the .dll export. Mismatched types in COMDAT groups cause /LTCG link errors, which are the MSVC equivalent of -Wodr violations:
LNK2005: "class Widget" already defined in widget.objThe fix on Windows is the same as everywhere else: ensure that all TUs β including those in DLLs β include the same version of the same headers with the same preprocessor definitions.
Practical checklist for large codebases
A large C++ codebase accumulates ODR risk as teams add features, macros, and shared libraries independently; systematic hygiene prevents violations from compounding. The following checklist addresses the most common sources of ODR violations at scale.
Include guard and #pragma once hygiene:
- Every header must have either
#pragma onceor matching#ifndef/#define/#endifguards - Guard macros must be globally unique β use
PROJECT_MODULE_FILENAME_Hnot justFILENAME_H - Audit for headers that are included both directly and transitively with different macro context
Macro discipline:
- Macros that affect class layout (
ENABLE_LOGGING,DEBUG_FIELDS, platform size types) must be set identically across all TUs - Use
target_compile_definitionsin CMake (notadd_definitions) to scope macros to specific targets - Add a static assert in headers that are sensitive to macro state:
// widget.hpp
#ifdef WIDGET_LOGGER
static_assert(sizeof(int) == 4, "widget.hpp macro inconsistency guard");
// Real guard: embed the macro value in a constexpr and check it at link time via LTO
inline constexpr bool kWidgetLoggerEnabled =
#ifdef WIDGET_LOGGER
true;
#else
false;
#endifLTO audit in CI:
# .github/workflows/odr-check.yml (example)
- name: ODR audit build
run: |
cmake -B build-lto \
-DCMAKE_CXX_FLAGS="-flto -Wodr -Wlto-type-mismatch -g" \
-DCMAKE_EXE_LINKER_FLAGS="-flto"
cmake --build build-lto 2>&1 | grep -E 'odr|type.mismatch|violation'Module migration path:
C++20 modules eliminate most header-based ODR risks by replacing the textual inclusion model with a compiled module interface. The migration path for a large codebase:
- Start with
module;(global module fragment) to wrap legacy headers - Convert leaf utilities first β types with no dependencies on other internal headers
- Keep ODR-sensitive headers as
#includeuntil they are fully modularised - Use
export importto re-export stable interfaces
// widget.cppm β module interface unit (no ODR risk from repeated inclusion)
export module widget;
export class Widget {
int id_;
public:
explicit Widget(int id) : id_(id) {}
int id() const { return id_; }
};
// No include guards needed; modules are compiled once and cached.Shared library versioning:
- Use symbol versioning scripts (
.mapfiles on Linux,.deffiles on Windows) to make ABI-breaking changes explicit - Run
orc --binaryorabidiffin the CI post-link step to catch type-layout changes before release