Skip to content
← Prism
Prism
Overview
Features
defer orelse zeroinit raw bounds-check auto-unreachable auto-static check
C in practice
goto skips init tag namespace struct padding strict aliasing char signedness signed overflow VLAs
Spec Draft Releases Blog
GitHub ↗

goto skips a variable initializationwhy C stays silent and C++ does not

A goto that jumps forward past a declaration leaves that variable in scope at the label, still uninitialized. C compiles it. C++ rejects the same source outright. The GCC warning that catches the C version is off by default, and the one warning that does fire under -Wall stops firing the moment you turn optimization on.

#The symptom

You are reading a C++ diagnostic and wondering why the equivalent C build is quiet:

error: jump to label 'done'
note:   from here
note:   crosses initialization of 'int x'

That message is C++ only. Here is the code that produces it:

int f(int c) {
    if (c) goto done;
    int x = 42;
    return x;
done:
    return x;      // x is in scope, never initialized
}

C++ makes this ill-formed under [stmt.dcl]/2: unless every block variable becoming active at the destination has vacuous initialization, "the transfer of control shall not be a jump". No flag required, and a footnote on the same paragraph extends it to switch, treating the transfer from the controlling expression to a case label as a jump. C has no equivalent constraint. GCC 11.4.0 emits object code for it and moves on.

#What C actually says

Two different rules are in play, and only one of them is a constraint.

Jumping into a block is legal. The declaration is skipped, but the identifier's scope is the whole enclosing block, so x exists at done:. Its lifetime began on entry to the block; only the initializer was bypassed. C23 6.7.11p11 then says that an object with automatic storage duration that is not initialized explicitly has an indeterminate representation, and reading one is on the programmer, not the implementation. The wording in C11 is 6.7.9p10, with the same effect.

The constraint list for goto lives in C23 6.8.7.2p1 (C11 6.8.6.1p1) and contains exactly one restriction beyond naming a label in the enclosing function: the variably modified type rule covered below. Skipping an ordinary initializer is not on the list, so a conforming implementation has nothing to diagnose.

#The warning that goes away at -O1

-Wjump-misses-init catches the whole class and is not in -Wall or -Wextra:

$ gcc -std=c11 -Wjump-misses-init -c f.c
f.c: In function 'f':
f.c:2:12: warning: jump skips variable initialization [-Wjump-misses-init]
    2 |     if (c) goto done;
      |            ^~~~
f.c:5:1: note: label 'done' defined here
    5 | done:
      | ^~~~
f.c:3:9: note: 'x' declared here
    3 |     int x = 42;
      |         ^

GCC's manual states that -Wjump-misses-init is included in -Wc++-compat. On GCC 11.4.0 it is not. gcc -Q --help=warnings -Wc++-compat reports -Wjump-misses-init [disabled], and the file above compiles clean under -Wc++-compat while that flag is demonstrably live on the same build (it warns on an implicit void * to int * conversion two lines later). Pass the flag by name.

There is a second warning, and its behaviour is the reason this bug survives review. -Wall catches the snippet above at -O0 and stops catching it at every level above:

BuildGCC 11.4.0, -Wall -Wextra
-O0warning: 'x' may be used uninitialized [-Wmaybe-uninitialized]
-O1silent
-O2silent
-O3silent
-Ogsilent
-Ossilent

-Wmaybe-uninitialized runs on the optimizer's own control-flow graph. Once the optimizer sinks the store to x past the label, the path that reads an uninitialized x no longer exists in the IR the warning inspects, so there is nothing left to report. The usual advice is to build warnings at -O2 because the analysis sees more. Here that advice loses you the only diagnostic -Wall was going to give you.

Delete the return x; on line 4 and the -O0 warning goes away too, at every level. A single unreachable return statement is the difference between a diagnostic and silence.

#switch does it too

Nobody writes the goto version by accident. The switch version gets written constantly, because a declaration between the controlling expression and the first case looks like ordinary scoping:

int f(int c) {
    switch (c) {
        int x = 42;     // never executed: no case label reaches it
    case 0:
        return x;       // reads an uninitialized x
    default:
        return 0;
    }
}

Every case label jumps past that initializer, so x is uninitialized on every path that can reach a read. GCC 11.4.0 under -Wall -Wextra reports -Wswitch-unreachable, which tells you a statement will not execute and says nothing about the read. -Wjump-misses-init names the actual problem, twice, once per label:

warning: switch jumps over variable initialization [-Wjump-misses-init]
warning: switch jumps over variable initialization [-Wjump-misses-init]

The flag also covers backward jumps to a label placed after the variable was initialized, which is the shape you get from a hand-rolled retry loop built out of goto.

#What it does at runtime

The failure mode is worse than a wrong value: it is a value that changes with optimization level. Same source, GCC 11.4.0:

int f(int c) {
    if (c) goto done;
    int x = 42;
done:
    return x;
}
/* main() calls f(1), then dirties the stack, then calls f(1) again */
-O0
cold call  : 61033
after noise: 51685
-O2
cold call  : 42
after noise: 42

At -O2 the optimizer sinks the store and the function appears to work. At -O0 it returns whatever the stack slot held, and the exact values differ per machine and per run. Note that this variant, with no return x; before the label, draws no warning at any optimization level under -Wall -Wextra. A test suite built at -O2 passes. The debug build is the one that breaks, which is the reverse of the usual expectation and makes the bug look nondeterministic.

#The one case C does reject

Variably modified types are a constraint violation, not undefined behaviour, so the compiler must diagnose them:

int f(int c, int n) {
    if (c) goto done;
    int vla[n];
    vla[0] = 1;
done:
    return 0;
}
error: jump into scope of identifier with variably modified type
note: label 'done' defined here
note: 'vla' declared here

This is an error with no flags at all, not even -pedantic. The reason is allocation rather than initialization. C23 6.8.7.2p1 forbids a goto from outside the scope of a variably modified identifier to inside it, because reaching done: without executing the VLA declaration means the array was never allocated and the compiler cannot generate correct stack adjustment. That is why this one is an error while int x = 42; is not.

#Fixing it in plain C

Three options, in rough order of preference.

Turn the warning on. Add -Wjump-misses-init to your build. It costs nothing, covers goto, switch and backward jumps, and is the only one of the three that scales to a codebase you did not write. If the codebase has existing hits, pair it with -Werror=jump-misses-init once they are cleared.

Hoist the declaration above the jump. Declare and initialize before any goto that can reach past it:

int f(int c) {
    int x = 0;          // initialized on every path
    if (c) goto done;
    x = 42;
    return x;
done:
    return x;
}

Scope the label instead of the variable. If the cleanup block does not need the variable, put the declaration in a nested block so it is not in scope at the label at all. The compiler then rejects any use at done:, which converts a silent runtime bug into a name-resolution error at build time. For the switch form the same move is to brace the case body: case 0: { int x = 42; return x; }.

#Why -ftrivial-auto-var-init is not a fix

-ftrivial-auto-var-init=zero is the current standard mitigation for uninitialized locals. Clang has shipped it since Clang 8, GCC since GCC 12. The Linux kernel enables it through CONFIG_INIT_STACK_ALL_ZERO, and Android's Generic Kernel Image builds with it. The reported cost is small enough that Android and ChromeOS run it in production.

It does not cover this page. From GCC's own documentation for the flag:

"However, the current implementation cannot initialize automatic variables whose initialization is bypassed through switch or goto statement. Using -Wtrivial-auto-var-init to report all such cases."

So GCC ships a warning, -Wtrivial-auto-var-init, whose entire job is to tell you where the mitigation does not reach. If you have turned on -ftrivial-auto-var-init and believe uninitialized locals are handled, turn that warning on too.

Clang had the same hole, reported in 2020 as llvm-project#44261, and has since closed it: bypassed variables are now initialized under both =zero and =pattern. The two compilers therefore disagree today on whether this construct is mitigated, which is a poor thing to have your threat model depend on. -Wjump-misses-init is portable to both and rejects the code at the source rather than papering over it.

#How Prism handles it

Prism is a C transpiler that treats this pattern as an error during CFG verification, before any C is emitted. Both forms on this page are rejected, and the exit status is 1:

$ prism -c f.c
f.c:3: pparse_error:     int x = 42;
                             ^ goto 'done' would skip over a declaration with
                               an initializer (undefined behavior if jumped into)

$ prism -c switch.c
switch.c:4: pparse_error:     case 0:
                              ^ case/default label may bypass declaration with
                                initializer (undefined if jumped into)

It reports jumps that skip a declaration in the same class as jumps that skip an active defer, because both leave the program in a state the source does not describe.

The interesting case is the one where the declaration has no initializer, because zero-initialization is on by default and would supply one. Prism does not lean on that. It refuses the construct instead:

$ prism -c bare.c
bare.c:4: pparse_error:         int x;
                                ^ variable declaration directly in switch body without braces
                                  (zero-init may be skipped by case labels); wrap in braces or use 'raw'

Which is the opposite of the -ftrivial-auto-var-init approach. GCC's flag silently does not reach a bypassed declaration and leaves you to find the gap with a second warning. Prism knows its own zero-init can be jumped over by a case label and makes that a build failure with the fix named in the message. The raw keyword opts a single variable out where the cost matters.

If you would rather stay on your existing toolchain, -Wjump-misses-init covers the initialization case on its own. It will not catch the defer interaction, because standard C has no defer.

#Further reading

Compiler behaviour on this page re-measured on GCC 11.4.0, Ubuntu 22.04. Runtime values vary by machine and build. Claims about GCC 12+, GCC 15+ and Clang are cited from vendor documentation and issue trackers rather than run here.