C++ extern Linkage: External vs Internal Linkage, extern "C" and the ODR

Key takeaways

A practical guide to C++ extern linkage: external vs internal linkage, deep coverage of extern "C", how linkage interacts with headers and the ODR, and library design patterns—with runnable examples.

Introduction

In C++, extern is the keyword you use when you want to share global variables or functions across multiple files. Understanding linkage is essential to using it correctly.


Internal linkage vs external linkage

ConceptMeaningTypical cases
External linkageThe same name in another translation unit (.cpp) can refer to one entityNon-static functions at file scope, extern global variable declarations/definitions
Internal linkageThe name is visible only inside that translation unitstatic file-scope variables/functions; default linkage rules for const objects (watch legacy rules); names inside an anonymous namespace
No linkageLocal variables, class members, etc.Resolved by scope rules, not by the linker

Practical angle: “If I put this in a header, do I violate the ODR?” usually comes down to linkage plus inline rules. You can put inline function definitions in a header, but definitions of globals normally live in a single .cpp.


Going deeper on extern "C"

C++ uses name mangling to implement overloading. The C ABI does not, so to link against C library symbols you must mark them with an extern "C" block—C language linkage.

  • Pointer types: When you pass a C++-written callback to an extern "C" function, the callback should also be extern "C", or you must document the calling convention explicitly.
  • No overloading: Under C linkage there is only one function with that linker-visible name per translation unit.
  • Header idiom: Wrap declarations in #ifdef __cplusplus so one header works from both C and C++ (same pattern as Example 3 below).
extern "C" int plugin_init(void* ctx);  // symbol exported with C naming rules

The most common symptom of a missing extern "C" is a linker error that names a function you know exists. If C++ code includes a C header that lacks the __cplusplus guard, the compiler assumes C++ linkage and asks the linker for a mangled name such as _Z11c_calculateii, while the C object file only provides c_calculate. GCC’s linker reports it as undefined reference to 'c_calculate(int, int)'. The parameter list in that message is the giveaway: a C symbol has no parameter types in its name, so if the linker prints them, it was looking for a C++ symbol. The fix is to wrap the include (extern "C" { #include "old_c_lib.h" }) or, better, add the guard to the header itself. The opposite mistake, extern "C" on a function declared twice with different parameters, fails at compile time with conflicting declaration of C function, because C linkage cannot express overloads.


Header guards, #pragma once, and linkage

Header guards (#ifndef / #define) and #pragma once prevent the preprocessor from including the same header twice in one translation unit. That is a different layer from linkage: guards prevent duplicate parsing; linkage is about how the linker merges symbols.

In practice you still treat these as one checklist:

  1. Declarations must be safe to include many times → #pragma once plus declarations only.
  2. Definitions must appear once → put them in .cpp, or satisfy inline / constexpr / similar rules.
  3. extern globals: extern int x; in the header, int x = 0; in exactly one .cpp.

Guards do not “change linkage”; they let you include headers safely everywhere, which makes obeying the ODR easier.


Practical library design tips

  1. Wrap the public API in a namespace and avoid sprinkling globals with extern.
  2. Put implementation details behind internal linkage (static, anonymous namespace) or a detail namespace.
  3. If you need a C API, use extern "C" and document a stable ABI (calling convention, thread safety).
  4. Prefer C++20 modules or a clear include hierarchy to limit what leaks from headers.

Basics of extern

Concept

Example C/C++ code:

// file1.cpp
int globalVar = 10;

// file2.cpp
extern int globalVar;
int x = globalVar;

Basic usage

// globals.h
extern int counter;
extern void increment();

// globals.cpp
#include "globals.h"
#include <iostream>

int counter = 0;

void increment() {
    counter++;
}

// main.cpp
#include "globals.h"
#include <iostream>

int main() {
    increment();
    std::cout << counter << std::endl;  // 1
}

Two details in this example are easy to misread. First, extern on a function declaration is redundant: functions at namespace scope already have external linkage, so void increment(); means exactly the same thing. People write it anyway for symmetry with the variables. Second, extern int counter; is what makes the header line a declaration rather than a definition. Without extern, int counter; in a header is a definition (zero-initialized), and adding an initializer to the extern form, extern int counter = 0;, also turns it back into a definition; GCC warns 'counter' initialized and declared 'extern'. Both forms compile until a second .cpp includes the header, and then the link fails.


Practical examples

Example 1: Global configuration

// config.h
#ifndef CONFIG_H
#define CONFIG_H

#include <string>

extern int maxConnections;
extern std::string serverName;

#endif

// config.cpp
#include "config.h"

int maxConnections = 100;
std::string serverName = "MyServer";

// main.cpp
#include "config.h"
#include <iostream>

int main() {
    std::cout << "Max: " << maxConnections << std::endl;
    std::cout << "Server: " << serverName << std::endl;
}

Example 2: Function declarations

// utils.h
#ifndef UTILS_H
#define UTILS_H

#include <string>

extern void printMessage(const std::string& msg);
extern int calculate(int a, int b);

#endif

// utils.cpp
#include "utils.h"
#include <iostream>

void printMessage(const std::string& msg) {
    std::cout << msg << std::endl;
}

int calculate(int a, int b) {
    return a + b;
}

// main.cpp
#include "utils.h"

int main() {
    printMessage("Hello");
    int result = calculate(5, 3);
    printMessage(std::to_string(result));
}

Example 3: extern "C"

// c_library.h
#ifdef __cplusplus
extern "C" {
#endif

void c_function();
int c_calculate(int a, int b);

#ifdef __cplusplus
}
#endif

// c_library.c
#include "c_library.h"
#include <stdio.h>

void c_function() {
    printf("C function\n");
}

int c_calculate(int a, int b) {
    return a + b;
}

// main.cpp (compile c_library.c with a C compiler, main.cpp with a C++ compiler)
#include "c_library.h"
#include <iostream>

int main() {
    c_function();
    int result = c_calculate(5, 3);
    std::cout << result << std::endl;
}

The guard only works if c_library.c is actually compiled as C. A build that feeds .c files to g++, or a Visual Studio project set to “Compile as C++”, compiles both sides with C++ linkage; everything links, and the header’s extern "C" block is what then breaks the build the day someone compiles the file as C again. In C, void c_function(); also means “unspecified parameters” rather than “no parameters”; void c_function(void); is the portable spelling for a header shared by both languages.


extern vs static

Comparison

// extern (external linkage)
extern int globalVar;

// static (internal linkage)
static int localVar;

Example

Sample main setup:

// file1.cpp
int externVar = 10;
static int staticVar = 20;

// file2.cpp
extern int externVar;
// extern int staticVar;  // error: not accessible

int main() {
    std::cout << externVar << std::endl;  // 10
}

Common pitfalls

Pitfall 1: Multiple definitions

Example:

// ❌ definition in header
// header.h
int globalVar = 10;

// ✅ declaration in header, definition in source
// header.h
extern int globalVar;

// source.cpp
int globalVar = 10;

The error arrives from the linker, not the compiler, because each .cpp compiles fine on its own: GNU ld prints multiple definition of 'globalVar'; first defined here, MSVC prints LNK2005: "int globalVar" already defined in main.obj. The reverse mistake, declaring extern int globalVar; everywhere but defining it nowhere, gives undefined reference to 'globalVar' / LNK2001. Since C++17 a third option exists for this case: inline int globalVar = 10; in the header. The linker is then required to merge all copies into one object, which is what header-only libraries use.

Pitfall 2: Initialization order

Example:

// file1.cpp
int a = computeA();   // dynamic initialization (runs at startup)

// file2.cpp
extern int a;
int b = a * 2;        // may read a before computeA() has run: b == 0

// ✅ function-local static
int& getA() {
    static int a = computeA();   // initialized on first call, thread-safe since C++11
    return a;
}

int b = getA() * 2;

The order of dynamic initialization between translation units is unspecified; it usually follows link order, which is why the bug appears or disappears when files are added to the build. Note that int a = 10; would not have this problem: a constant initializer is applied before any dynamic initialization, so it is always ready. The danger comes from globals whose initializer runs code, such as std::string, containers, or anything calling a function. The static initialization order post covers the symptoms in more detail.

Pitfall 3: const and extern

Example:

// ❌ const defaults to internal linkage at namespace scope (in typical cases)
// header.h
const int MAX = 100;

// ✅ explicit extern
// header.h
extern const int MAX;

// source.cpp
#include "header.h"   // required: without it, this MAX has internal linkage
const int MAX = 100;

The #include in source.cpp is not decoration. A const at namespace scope gets internal linkage unless an earlier declaration in the same translation unit already gave it external linkage. If source.cpp does not see extern const int MAX; first, its MAX stays private to that file and every other file fails to link with undefined reference to 'MAX'. Writing extern const int MAX = 100; in the source file avoids depending on the include.

The first version, const int MAX = 100; directly in the header, is usually not a bug at all: each translation unit gets its own copy, which for a small integer costs nothing and lets the compiler fold the value into expressions. The extern form has a real drawback: other files only see a declaration, so MAX is no longer a constant expression there and cannot be used as an array bound or template argument. For constants, C++17 inline constexpr int MAX = 100; in the header gives one shared object and a compile-time value, and is what I use now instead of either form above.

Pitfall 4: Namespaces

Example:

// ❌ polluting the global namespace
extern int counter;

// ✅ prefer a namespace
namespace MyApp {
    extern int counter;
}

extern template

Explicit instantiation

// header.h
template<typename T>
class MyClass {
public:
    void print(T value);
};

extern template class MyClass<int>;

// source.cpp
template class MyClass<int>;

// main.cpp
#include "header.h"

int main() {
    MyClass<int> obj;
}

extern template is a different use of the keyword with nothing to do with variables: it tells every file that includes the header “do not instantiate MyClass<int> here, one .cpp does it”. The goal is build time, since otherwise each translation unit that uses MyClass<int> compiles the same member functions and the linker throws the duplicates away. For this to work, source.cpp must see the definitions of the members (here, print is only declared in the header, so the explicit instantiation in source.cpp needs the body available there), and the explicit instantiation must exist exactly once, or calls to obj.print(1) fail with an undefined reference. It pays off for heavy templates used with a few fixed types; for small class templates the saving is not worth the extra bookkeeping.


Another practical example

Example 1: Logger system

// logger.h
#ifndef LOGGER_H
#define LOGGER_H

#include <string>

extern int logLevel;
extern void log(const std::string& message);

#endif

// logger.cpp
#include "logger.h"
#include <iostream>

int logLevel = 1;

void log(const std::string& message) {
    if (logLevel > 0) {
        std::cout << "[LOG] " << message << std::endl;
    }
}

// main.cpp
#include "logger.h"

int main() {
    logLevel = 2;
    log("Application started");
}

This logger works, but it shows why Pitfall 4 matters. A global function named log sits next to the C library’s double log(double) from <cmath>. It is a legal overload, yet any call like log(someInt) silently picks the math function, and a reader of main.cpp cannot tell which log is meant. Putting logLevel and log inside a namespace (app::log) costs nothing and removes the collision. The mutable logLevel global also means any file can change logging behaviour for the whole program, which is convenient in a small tool and a debugging headache in a large one; a setter function behind internal linkage keeps the variable itself private.


Choosing the linkage for a global

Propertyexternstatic
LinkageExternalInternal
Visible in other TUsYesNo
Multiple definitionsNo (one definition rule)Yes (per TU)
Typical useShared globalsFile-local helpers

Pick by what the name is for. A helper function or variable used by one .cpp file should have internal linkage: static or, preferably, an unnamed namespace, which also works for types. A mutable variable shared across files needs exactly one definition: extern declaration in the header and the definition in one source file, or a C++17 inline variable in the header. A constant shared across files is best written as inline constexpr in the header, which gives one object and a compile-time value. Use extern "C" only at the boundary where C code or another language has to find the symbol by its unmangled name.

Next steps



Frequently Asked Questions (FAQ)

A. A namespace-scope const or constexpr variable that is not declared extern has internal linkage, so every .cpp file that includes the header gets its own private copy and the linker never sees a clash. A plain non-const global has external linkage, so defining it in a header included by two .cpp files produces two definitions of the same symbol. For a shared mutable global, put extern int x; in the header and the one definition in a single .cpp file, or use a C++17 inline variable.