A C# type can fail while its static fields are being initialized if an earlier field initializer reads a later field that still contains its default value. The outer exception is commonly TypeInitializationException, while the actionable cause is available through InnerException. This guide explains textual ordering, default values, exception wrapping, eager and lazy corrections, and a reproducible .NET 9 test with observed results.

Direct answer: why C# static fields depend on declaration order

Static field variable initializers in one type execute in textual order. If an earlier initializer reads a later reference-type field, that later field still contains its default null value, so static initialization can fail before the intended application path begins.

The important distinction is between a field having an initializer in source code and that initializer having already executed. All static fields first receive their default values, after which their field initializers run in textual order. If the class has no explicit static constructor, the specification permits those initializers to run at an implementation-dependent time before the first use of one of the class's static fields. If All calls BuildAll() before the declaration of Source has been processed, the method observes null even though Source visibly contains new() a few lines later.

This defect often looks intermittent because the observed trigger can be deferred until a path first uses the relevant static field. A test runner, reflection scan, or dependency registration step can also shift where the failure becomes visible. The field ordering itself is deterministic; the implementation-dependent initialization timing and triggering path affect where the exception is observed. Once initialization has failed in a process, repeatedly touching the same static field is not a dependable recovery design. Correct the dependency and verify it in a fresh process.

Read InnerException before changing the code

TypeInitializationException identifies a failed type initializer, not the underlying defect. Capture the affected type, the complete exception chain, and the innermost stack trace before deciding whether the problem is ordering, invalid data, formatting, or another initialization action.

The .NET API documentation explains that an exception thrown by a static constructor is wrapped by the runtime and made available through InnerException. Field initializers participate in type initialization as well, so an apparently generic outer exception can retain a precise inner cause. In the controlled example below, constructing a list from the not-yet-initialized source produces ArgumentNullException, and the runtime presents TypeInitializationException to the caller.

Do not make the symptom disappear by catching every exception and returning an empty collection. That converts an immediate, observable defect into incorrect data that may surface much later. Likewise, adding null tolerance inside every builder is not a sound correction when the source is required by design. The structural correction should make the dependency order explicit or remove the ordering dependency altogether.

A useful diagnostic review starts with every static field initializer and static constructor in the affected type. List which other static members each expression reads. Then identify I/O, mutable global state, environment-dependent values, and callbacks. This dependency map is more reliable than guessing from the line where the outer exception was first observed because the first caller is only a trigger, not necessarily the source of failure.

Fix one: declare dependencies before derived static data

When initialization is pure, fast, and always required, the smallest correction is to declare source fields before derived fields. The resulting textual order matches the dependency direction and gives reviewers a straightforward model to inspect.

The following example includes both an ordered class and a lazy class. Ordered.Source is assigned before Ordered.All calls the builder. Lazy.All postpones the builder until the property is requested. The sample uses only fixed in-memory strings and performs no network access, account access, database work, payment, or irreversible operation.

static class Ordered
{
    private static readonly List Source = new() { "alpha" };
    public static readonly List All = BuildAll();

    private static List BuildAll() => new(Source);
}

static class Lazy
{
    private static List? _all;
    private static readonly List Source = new() { "alpha" };
    public static List All => _all ??= BuildAll();

    private static List BuildAll() => new(Source);
}

Reordering has attractive properties: it is easy to understand, adds no synchronization object, and preserves eager behavior. Its weakness is continued reliance on a source-layout rule. A future refactor can move the derived field above its dependency and recreate the bug. If eager initialization remains appropriate, add a regression test that touches the type and make dependency-first ordering an explicit review requirement.

Partial type declarations deserve extra caution. The C# specification defines ordering rules, but code distributed across separate partial declarations should not be designed around a presumed file ordering. A stable design should avoid cross-part textual dependencies. Consolidate tightly related initialization, use an explicit factory with clear inputs, or move the state behind a service whose construction order is controlled by the composition root.

Ordering also does not solve mutability. A publicly exposed List can be modified after correct initialization, creating a different class of global-state defect. Lookup data should generally be represented by a read-only view, immutable collection, or defensive projection, depending on the contract. Initialization correctness and post-initialization ownership need separate tests.

Fix two: remove the textual dependency with lazy initialization

Lazy initialization is appropriate when derived data is expensive, may never be requested, or should not be created merely because the type is loaded. Select a mechanism whose concurrency behavior matches the actual guarantee instead of treating one syntax pattern as universal.

The compact _all ??= BuildAll() pattern postpones work, but it does not by itself guarantee that a factory executes only once under concurrent access. Two threads can both observe null and both run the builder. If duplicate pure work is unacceptable, choose a mechanism with explicit synchronization semantics, document its state transitions, and add a multithreaded test. Decide whether the requirement is “a usable result is eventually retained” or “the factory runs exactly once.”

Lazy is not a reason to place external I/O in type initialization. Reading changing configuration, contacting a remote system, or starting background work during static access couples type availability to environmental conditions and complicates cancellation, retries, and tests. Move such work into an injected service with an explicit lifetime. Keep static initialization for constants and data that can be derived by deterministic, side-effect-free logic.

Failure behavior also needs a deliberate contract. A deferred factory must define whether later reads retain a prior failure or allow an explicit retry. Retaining a deterministic programming failure can be reasonable, but silently retrying transient external work during static access makes cancellation and observability difficult. Deterministic in-memory construction may fit deferred static state; recoverable environmental work belongs behind an explicit service boundary.

Finally, consider ownership of the value returned by a lazy property. Returning the same mutable object makes every caller a potential writer. If the value is intended as shared reference data, prefer an immutable representation or expose operations instead of the collection itself. The verification in this article checks that two reads return the same cached object only to demonstrate the sample’s behavior; it does not claim that a mutable shared list is the recommended public API.

Verification and reproduction on .NET SDK 9.0.305

This test was executed in the scheduler environment with .NET SDK 9.0.305. The Release build completed with zero warnings and zero errors, and the run showed wrapped failure, successful dependency-first initialization, and a cached sample object returned by consecutive lazy-property reads.

  1. Create a new isolated folder, add global.json with SDK version 9.0.305 and rollForward set to disable, then create a net9.0 console project.
  2. Add Bad, Ordered, and Lazy classes. In Bad, place All before Source; in the other classes, apply the two corrections described above.
  3. Run dotnet build --configuration Release && dotnet run --no-build --configuration Release, then compare each marker with the explicit pass and fail conditions below.

The essential failing case and observer are shown here. It is safe to run in a local test project and does not contact an external service.

using System.Collections.Generic;

try
{
    _ = Bad.All;
    Console.WriteLine("BAD:NO_EXCEPTION");
}
catch (TypeInitializationException ex)
{
    Console.WriteLine($"BAD:{ex.GetType().Name}:{ex.InnerException?.GetType().Name}");
}

Console.WriteLine($"ORDERED:{Ordered.All.Count}");
Console.WriteLine($"LAZY:{Lazy.All.Count}:{ReferenceEquals(Lazy.All, Lazy.All)}");

static class Bad
{
    public static readonly List All = BuildAll();
    private static readonly List Source = new() { "alpha" };
    private static List BuildAll() => new(Source);
}

static class Ordered
{
    private static readonly List Source = new() { "alpha" };
    public static readonly List All = BuildAll();
    private static List BuildAll() => new(Source);
}

static class Lazy
{
    private static List? _all;
    private static readonly List Source = new() { "alpha" };
    public static List All => _all ??= BuildAll();
    private static List BuildAll() => new(Source);
}

Expected output:

BAD:TypeInitializationException:ArgumentNullException
ORDERED:1
LAZY:1:True

The observed markers were BAD:TypeInitializationException:ArgumentNullException, ORDERED:1, and LAZY:1:True. The first confirms that the later source field was still null and that the runtime retained the inner failure. The second confirms that dependency-first ordering built one fixed item. The third confirms that consecutive property reads returned the same cached sample object. These are semantic observations, not benchmark or throughput claims.

The failing conditions matter as much as the passing conditions. BAD:NO_EXCEPTION means the deliberately broken ordering was not preserved. A different inner exception means the framework, constructor, or sample code has changed and should be investigated. An ordered count other than one means the fixed test input was altered. A final lazy marker other than True means the property no longer retains the built value.

Run each major variant in a fresh process. Static initialization state belongs to the process and can make tests influence each other if multiple cases reuse the same loaded type. A process-level test is especially valuable when a unit test framework might initialize a type during test discovery. Keep the project isolated and fixed to the documented SDK so a later toolchain update does not silently change the evidence set.

Code review and regression checklist

A reliable review traces static dependencies first, separates pure construction from environmental work, defines concurrency and ownership, preserves the complete exception chain, and exercises the type from a fresh process with observable assertions.

  • Enumerate every static field initializer and static constructor in the type.
  • Record every other static member read by each initializer and look for cycles or forward references.
  • Put pure source data before pure derived data, or adopt a lazy mechanism that removes layout dependence.
  • Move configuration access, remote calls, background tasks, cancellation, and retry logic into explicit services.
  • Specify whether concurrent access may perform duplicate construction or must guarantee one factory execution.
  • Specify whether callers receive an immutable value, a read-only view, a defensive copy, or shared mutable state.
  • Assert the outer and inner exception types instead of checking only that “an exception occurred.”
  • Launch a fresh process for regression cases that depend on first type access.
  • Pin the SDK and preserve exact output markers so changes remain reviewable.

For an older static utility, refactor in narrow stages. First add a test that reproduces the current ordering defect. Next extract the builder into a pure function with explicit arguments. Then decide whether eager ordering, a documented deferred mechanism, or an injected service matches the lifetime. Finally, remove the old entry point after callers use the new contract. This sequence keeps every change attributable and avoids replacing a small deterministic bug with a broad unverified redesign.

A regression suite should include both negative and positive paths. The negative path proves that the intentionally forward reference still demonstrates the expected wrapper and inner exception. The positive paths prove that each supported correction creates the expected data. If immutability or exact-once concurrency is part of the public contract, add separate tests for those properties rather than inferring them from a successful single-threaded run.

Official evidence and compatibility boundaries

The shared conclusions were checked against the C# language specification and the .NET API reference. Both official HTTPS pages were opened successfully with HTTP 200, while runtime behavior was verified separately in the pinned local project.

The static field initialization section of the C# specification states that static field variable initializers correspond to assignments performed in the textual order in which they appear in the class declaration. Official source: .

The TypeInitializationException API reference explains the wrapper created when a class initializer throws and directs readers to InnerException for the underlying cause. Official source: .

These sources establish ordering and exception semantics; they do not say that every static field should become lazy. Eager dependency-first initialization is a reasonable fit for cheap, pure, always-needed values. A documented deferred mechanism fits values that need an explicit creation policy. An injected service fits work that depends on changing configuration, I/O, cancellation, retry, or explicit disposal. Compatibility review should therefore preserve the language guarantees while choosing the lifecycle mechanism according to the application contract.

The test targets .NET 9 because that fixed SDK is installed and executable in the scheduler environment. The textual-order rule is a C# language rule rather than a benchmark result tied to one machine. Teams targeting another supported runtime should run the same isolated test with their exact SDK and target framework, record the observed inner exception, and keep the evidence with the change. That process verifies their toolchain without inventing cross-version performance or reliability claims.