C Basics · beginner · ~10 min

Header files

By the end of this lesson you will be able to: - Split a C program into `.h` (interface) and `.c` (implementation) files cleanly. - Explain the difference between a *declaration* and a *definition*, and why C needs both. - Use `#include` correctly, and know when to use `"quotes"` versus `<angle brackets>`. - Write include guards (`#ifndef`/`#define`/`#endif`) and `#pragma once` to prevent double-inclusion. - Compile and link a multi-file program from the command line. - Decide what belongs in a header (the public interface) and what stays private in a `.c` file.

Overview

So far your programs have lived in a single .c file. That works for tiny exercises, but real programs are built from many files written by many people. Header files are how C lets one file use functions and types that are written in another file.

Think of a header as a menu and the source file as the kitchen. The menu (.h) tells you what dishes exist and what they are called, without revealing how they are cooked. The kitchen (.c) does the actual cooking. A caller only needs to read the menu to place an order; it never needs to walk into the kitchen.

This builds directly on the Functions lesson. There you learned that a function has a prototype (its name, return type, and parameter list) and a body (the code that runs). A header file is simply the natural home for that prototype, so that any other file can call your function without copying the prototype by hand.

Why does C need this at all? The C compiler reads one file at a time and works top-to-bottom. When it sees a call to sum(2, 3), it must already know that sum exists and what types it takes and returns — otherwise it cannot generate correct machine code or catch type mistakes. The header supplies that knowledge. Later, a separate step called linking connects the call to the real function body, which may live in a completely different .c file.

Key terms you will meet: declaration (a promise that something exists), definition (the real thing), translation unit (one .c file plus everything it includes), preprocessor (the text-substitution step that runs before compilation), and linker (the tool that stitches compiled pieces together).

Why it matters

Header files are the foundation of how C scales beyond toy programs:

  • Reuse. The C standard library, every operating-system API, and every third-party library you will ever use ships as headers plus a precompiled library. You include <stdio.h> to get printf without ever seeing its source.
  • Separate compilation. Each .c file can be compiled on its own. Change one file and you only recompile that file, not the whole project — critical when a codebase has thousands of files.
  • Clear interfaces. A header is a contract. It says exactly what a module offers, and hides the messy internals. This is the same idea as an API or a class's public methods in other languages.
  • Team work. One developer can write code against your header before your implementation is finished, because the header is the agreed shape of the work.
  • Fewer bugs. When a function's prototype lives in one shared header, the compiler checks every call against the same single source of truth. Mismatched argument types are caught at compile time instead of crashing at runtime.

Get headers wrong and you get the two most common build errors beginners hit: undefined reference (the linker cannot find a definition) and multiple definition / redefinition (the same thing was defined or included twice). Understanding headers removes most of those for good.

Core concepts

1. Declaration vs. definition

A declaration introduces a name and its type but does not allocate storage or provide code. A definition is the one place where the actual thing is created.

  • int sum(int a, int b); — a declaration (a function prototype). It promises that sum exists somewhere.
  • int sum(int a, int b) { return a + b; } — a definition. It supplies the body.

The rule of thumb: headers declare, .c files define. A name may be declared many times (every file that includes the header sees the declaration), but it must be defined exactly once across the whole program. This is called the One Definition Rule.

When to use: put a prototype in a header whenever a function is meant to be called from another file. Keep the definition in exactly one .c file.

Pitfall: putting a function body in a header. Every file that includes the header then gets its own copy of the definition, and the linker complains about multiple definitions.

Knowledge check: Is int sum(int a, int b); a declaration or a definition? What is the difference between the two?

2. The preprocessor and #include

Before the real compiler runs, a step called the preprocessor handles all the lines that start with #. The directive #include "foo.h" is the simplest: it literally copies the entire text of foo.h into the current file at that exact spot. Nothing clever happens — it is pure copy-and-paste of text.

main.c                          after preprocessing (one translation unit)
+-----------------------+       +-----------------------------+
| #include "math.h"     |  -->  | int sum(int a, int b);      |  <- pasted from math.h
| int main(void){       |       | int main(void){             |
|   return sum(2,3);    |       |   return sum(2,3);          |
| }                     |       | }                           |
+-----------------------+       +-----------------------------+

The combined text the compiler actually sees — your .c file plus everything it pulled in — is called a translation unit.

Quotes vs. angle brackets:

Form Search order Use for
#include "foo.h" your project directory first, then system paths your own headers
#include <stdio.h> system/compiler include paths only standard and library headers

Pitfall: including a .c file (#include "helper.c"). This pastes the definitions in and then they also get compiled separately, causing multiple-definition errors. Include .h files, compile .c files.

Knowledge check: After preprocessing, what does the compiler actually see when a file contains #include "math.h"?

3. Include guards and double-inclusion

Headers often include other headers. It is easy for the same header to be pasted into one translation unit twice — for example if both a.h and b.h include types.h, and your .c includes both. The second paste re-declares everything, and for types like structs that is an error.

An include guard stops this. It uses a preprocessor macro as a one-time flag:

First time math.h is pasted:        Second time math.h is pasted:
  MATH_H not defined yet              MATH_H is already defined
  -> define MATH_H                    -> #ifndef is false
  -> keep the declarations            -> skip everything to #endif
#ifndef MATH_UTILS_H   // if this macro is NOT defined...
#define MATH_UTILS_H   // ...define it now, so next time we skip
// declarations here
#endif                 // end of the guarded region

The modern one-line equivalent is #pragma once at the top of the header. It is supported by every mainstream compiler and is harder to get wrong (no name to clash), though the #ifndef form is the most portable and universally guaranteed by the standard.

When to use: every header you write should have a guard. There is no downside.

Pitfall: copy-pasting a header to make a new one and forgetting to change the guard macro name. Two different headers with the macro UTILS_H will silently shadow each other — the second one's contents get skipped.

Knowledge check (predict the result): Two different headers both start with #ifndef UTIL_H / #define UTIL_H. A .c file includes both. What happens to the declarations in the second header, and why?

4. What belongs in a header

A header is your module's public interface. Put in it only what callers need:

  • Function prototypes meant to be called from other files.
  • Type definitions (struct, enum, typedef) shared across files.
  • Macro and constant declarations callers depend on.

Keep out of it: function bodies, private helper functions, and large internal data. Anything that does not need to be public stays in the .c file (and can be marked static so it is invisible outside that file).

Pitfall: dumping everything into one giant header that every file includes. A change forces the entire project to recompile, and unrelated code becomes coupled. Keep headers small and focused.

Syntax notes

A header/source pair has a predictable shape.

/* math_utils.h - the interface (declarations only) */
#ifndef MATH_UTILS_H        // include guard opens
#define MATH_UTILS_H

int sum(int a, int b);      // prototype: name + types, no body, ends with ;
int product(int a, int b);  // another declaration

#endif                      // include guard closes
/* math_utils.c - the implementation (definitions) */
#include "math_utils.h"     // include OUR OWN header to keep declarations in sync

int sum(int a, int b) {     // definition: prototype + body, no trailing ;
    return a + b;
}
int product(int a, int b) {
    return a * b;
}

Notes:

  • A prototype ends with a semicolon; a definition ends with a } and no semicolon.
  • The .c file includes its own .h. This is a good habit: if the header and the body ever disagree on types, the compiler catches it immediately.
  • Use "quotes" for your own headers, <angle brackets> for system headers.

Lesson

What headers and source files do

C code is normally split across two kinds of files:

  • A header file (.h) declares functions, types, and macros. A declaration says what something is called and what its shape is, without providing the actual code.
  • A source file (.c) defines them. A definition provides the real implementation.

How #include works

The line #include "foo.h" literally pastes the contents of foo.h into the source file at that exact point, before the compiler runs.

Include guards

Headers often include other headers. Without protection, the same header can be pasted in more than once, causing errors.

To prevent this double-inclusion, wrap the header in an include guard:

#ifndef FOO_H
#define FOO_H
// ... declarations ...
#endif

The modern shortcut #pragma once does the same job in one line.

Keep headers small

A header is effectively your library's public interface. Keep it as small as possible, and expose only what callers need.

Code examples

/* ---------- math_utils.h : the public interface ---------- */
#ifndef MATH_UTILS_H
#define MATH_UTILS_H

int sum(int a, int b);       /* declare: callers may use these */
int product(int a, int b);

#endif /* MATH_UTILS_H */


/* ---------- math_utils.c : the implementation ---------- */
#include "math_utils.h"      /* keep definitions in sync with declarations */

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

int product(int a, int b) {
    return a * b;
}


/* ---------- main.c : a caller ---------- */
#include <stdio.h>           /* system header: angle brackets */
#include "math_utils.h"      /* our header: quotes */

int main(void) {
    int a = 6, b = 7;
    printf("sum     = %d\n", sum(a, b));      /* call across files */
    printf("product = %d\n", product(a, b));
    return 0;
}

What it does. Three files form one program. math_utils.h declares two functions. math_utils.c defines them. main.c includes the header so it knows the prototypes, then calls the functions. The header is included in two places (its own .c and main.c), which is exactly the normal case the include guard protects against.

How to build it (each .c is compiled separately, then linked together):

gcc -Wall -Wextra -std=c11 main.c math_utils.c -o app
./app

Expected output:

sum     = 13
product = 42

Edge cases. If you forget to list math_utils.c on the compile line, the code still compiles (the prototype satisfies the compiler) but fails to link with an "undefined reference to sum" error — the classic sign that a definition is missing. If you accidentally #include "math_utils.c", you will get a "multiple definition" error instead.

Line by line

Trace what happens when you run gcc main.c math_utils.c -o app. The toolchain works in stages.

Stage 1 - preprocessing (per file).

  • For main.c: #include <stdio.h> pastes in the standard I/O declarations; #include "math_utils.h" pastes in the two prototypes. The include guard fires for the first time, so MATH_UTILS_H becomes defined and the declarations are kept.
  • For math_utils.c: #include "math_utils.h" pastes the same two prototypes in, guarded the same way.

Stage 2 - compilation (per file). Each preprocessed translation unit is compiled to an object file independently.

File Sees prototypes? Sees sum body? Result
main.c yes (from header) no main.o with an unresolved call to sum
math_utils.c yes (from header) yes (in same file) math_utils.o containing the real sum code

Because main.c has the prototype, the compiler knows sum takes two ints and returns an int, so it generates a correct call and type-checks the arguments — even though the body is nowhere in this file.

Stage 3 - linking. The linker takes main.o and math_utils.o and resolves references. It finds the unresolved sum in main.o and matches it to the sum defined in math_utils.o. Same for product. With every reference resolved, it produces the executable app.

Stage 4 - run. main sets a = 6, b = 7. sum(6, 7) runs the linked body and returns 13; product(6, 7) returns 42. printf prints both lines. main returns 0.

The key insight: the header lets the compiler trust that sum exists, and the linker later supplies the actual code. Declaration and definition meet at link time.

Common mistakes

1. Defining a function in the header.

/* utils.h - WRONG */
int sum(int a, int b) { return a + b; }   /* a definition in a header */

Every .c that includes utils.h now contains its own copy of sum's body. The linker sees the same function defined multiple times: multiple definition of 'sum'. Fix: declare in the header, define once in a .c.

/* utils.h - RIGHT */
int sum(int a, int b);   /* declaration only */

2. Forgetting to compile/link the .c file.

gcc main.c -o app        # WRONG: math_utils.c is missing

Compiles fine (the prototype is enough), then fails: undefined reference to 'sum'. Fix: list every source file: gcc main.c math_utils.c -o app. Recognize it by the word reference (a link-time problem) rather than a compile error.

3. No include guard.

When a header that defines a struct or typedef is pasted in twice, you get redefinition of 'struct ...'. Fix: wrap every header in #ifndef/#define/#endif or put #pragma once at the top.

4. Reused guard macro after copy-paste.

You copy utils.h to strutils.h but leave #ifndef UTILS_H unchanged. Now whichever is included second is silently emptied, and you get undefined reference errors for functions you did declare. Fix: give each header a unique guard name matching the file (STRUTILS_H), or use #pragma once.

5. Including a .c file. #include "helper.c" pastes definitions in and the file is also compiled normally, causing duplicate definitions. Fix: include .h files only; compile .c files.

Debugging tips

Compiler errors (one file at a time):

  • implicit declaration of function 'sum' — you called sum without its prototype in scope. You forgot to #include the header, or the include is below the call. Fix the include.
  • redefinition of 'struct point' / 'foo' redeclared — the same header was pasted twice. Add or fix the include guard.
  • error: expected ';' right after an #include line — the included header is missing a semicolon on its last prototype; the error often points at the next file because of pasting.

Linker errors (whole program):

  • undefined reference to 'sum' — a definition is missing. Either you forgot to compile the .c that defines it, or the function name/signature does not match the declaration. Confirm the .c is on the build command and the signatures match exactly.
  • multiple definition of 'sum' — defined more than once: a body in a header, or a .c included by mistake. Move the body to one .c.

Concrete steps when a multi-file build fails:

  1. Read whether the error says compile (a file problem) or reference (a link problem). They have different causes.
  2. Compile each file to an object alone: gcc -c math_utils.c. This isolates which file is broken.
  3. Run only the preprocessor to see exactly what the compiler gets: gcc -E main.c | less. You can watch your header get pasted in.
  4. Enable warnings: always build with -Wall -Wextra. Implicit-declaration warnings catch missing includes early.

Questions to ask: Does every function I call have a prototype in scope? Is every function I call defined in exactly one compiled .c? Does each header have a unique guard? Did I accidentally include a .c?

Memory safety

Headers themselves declare interfaces, so the main "safety" concerns here are about correctness and robustness rather than memory corruption — but they directly prevent a class of subtle bugs:

  • Signature mismatch = undefined behavior. If a header declares int sum(int, int) but the .c defines long sum(long, long), the compiler may not catch it across files, and the linker can match them anyway. Calls then pass the wrong-sized arguments, which is undefined behavior and can corrupt values silently. Defense: always #include a function's own header inside its .c file so the compiler verifies the body against the declaration.
  • Stale prototypes. Change a parameter list in the .c but not the .h, and callers compile against the old shape. Defense: the single-source-of-truth rule — the prototype lives in the header only, and the .c includes that header.
  • Macros in headers leak globally. A #define MAX 100 in a widely-included header silently rewrites any token MAX everywhere. Prefer static const int MAX = 100; or an enum, and use unique, prefixed macro names.
  • Do not declare global variables with definitions in a header. Writing int counter = 0; in a header defines counter in every translation unit (multiple-definition error or, worse, duplicate storage). Declare with extern int counter; in the header and define it once in a .c.

None of these crash immediately, which is what makes them dangerous: they produce wrong results or fragile builds. Treating the header as the one authoritative contract eliminates them.

Real-world uses

Real-world use. Every C library you have ever used works this way. The C standard library gives you <stdio.h>, <stdlib.h>, <string.h> — declarations only — while the actual code lives in a precompiled libc. Operating-system APIs (the Linux kernel headers, the Windows SDK, POSIX <unistd.h>) are headers you include to call functions implemented elsewhere. SQLite ships as sqlite3.h plus a single implementation file. SDL, OpenSSL, zlib — all the same pattern: a public header is the contract, the binary library is the kitchen.

Professional best practices.

Beginner habits:

  • Pair each module: foo.h (interface) and foo.c (implementation), named the same.
  • Every header gets an include guard or #pragma once.
  • Every .c includes its own header first, then other project headers, then system headers.
  • Headers declare; .c files define. Definitions live in exactly one place.
  • Build with -Wall -Wextra so missing includes and mismatches surface early.

Advanced habits:

  • Keep headers minimal — expose only the public API; mark internal helpers static in the .c.
  • Avoid including heavy headers inside other headers; forward-declare types where possible to cut compile times and coupling.
  • Use extern for shared globals (declare in header, define once).
  • Group constants as enums or static const rather than macros to keep type checking.
  • Order: a header should be self-contained — including it alone must compile, meaning it includes everything it itself needs.

Practice tasks

Beginner 1 - Split a single file. Take a one-file program that has int square(int n) and main. Move square into geometry.c and its prototype into geometry.h with a proper include guard. Have main.c include the header. Build with gcc main.c geometry.c -o app and confirm the output is unchanged. Concepts: declaration vs. definition, #include.

Beginner 2 - Add a second function. Extend the previous module with int cube(int n). Declare it in the header, define it in geometry.c, and call both from main. Input n = 3 should print square = 9 and cube = 27. Concepts: growing a public interface, keeping header and source in sync.

Intermediate 1 - Trigger and fix each error. Deliberately (a) remove geometry.c from the build command, (b) move a function body into the header, and (c) delete the include guard and include the header twice. For each, record the exact error message and which stage (compile vs. link) produced it, then fix it. Concepts: undefined reference, multiple definition, redefinition.

Intermediate 2 - Shared type and a constant. Create point.h declaring struct Point { int x, y; };, a prototype int manhattan(struct Point a, struct Point b);, and a shared enum { MAX_COORD = 1000 };. Implement manhattan (sum of absolute coordinate differences) in point.c. Include point.h from two different .c files to prove the include guard works. Concepts: types in headers, include guards protecting structs, enums vs. macros.

Challenge - A mini math library. Build a reusable module mathx with header mathx.h and source mathx.c exposing at least: int gcd(int a, int b), long ipow(int base, int exp), and int clamp(int v, int lo, int hi). Requirements: include guard, the .c includes its own header, an internal static helper that is NOT declared in the header, input validation (e.g. clamp must handle lo > hi sensibly), and a main.c that exercises every function. Build all files together. Hint: keep the header free of bodies; verify there are no warnings under -Wall -Wextra. Concepts: full interface/implementation separation, static for private helpers, robustness.

Summary

  • A header (.h) holds declarations — the public interface. A source file (.c) holds definitions — the implementation. Headers declare; .c files define.
  • A name may be declared many times but must be defined exactly once across the program (the One Definition Rule).
  • #include is pure text substitution done by the preprocessor: it pastes a header's contents into the file. Use "quotes" for your own headers, <angle brackets> for system headers.
  • Wrap every header in an include guard (#ifndef/#define/#endif) or #pragma once to stop double-inclusion.
  • Compilation happens per file (the prototype is enough to compile a caller); the linker later connects each call to its definition. Missing definition → undefined reference; defined twice → multiple definition.
  • Have each .c include its own header so the compiler verifies the body matches the declaration. Keep headers small and focused — they are your module's contract.

Practice with these exercises