Quick answer: C# field initializers and constructors run before the node is attached to the scene tree, so GetNode<T>(path) returns null. Move the lookups into _Ready, use unique name references (%Name) for rename resilience, and combine null checks with IsInstanceValid for cached references.
You port a scene from GDScript to C#. The @onready lines become field initializers like private HealthBar _hp = GetNode<HealthBar>("UI/HealthBar");, the project compiles, and the first time you run you get NullReferenceException on the very first frame. The path looks right in the editor, the node exists, and yet C# sees nothing. This is the first C#-in-Godot gotcha every new port hits.
Why GetNode Is Null
Godot’s scene tree has a specific lifecycle: _EnterTree, then _Ready, then _Process. Before _EnterTree, the node exists as a C# object but has no parent, so GetNode has no tree to traverse.
C# field initializers run in the constructor, which executes during new(). At that point the node has not been added to the tree yet. Any GetNode call from that context returns null.
public partial class Player : CharacterBody2D {
// BAD: runs before the node is in the tree
private HealthBar _hp = GetNode<HealthBar>("UI/HealthBar");
public override void _Process(double delta) {
_hp.Value = health; // NullReferenceException
}
}
Fix 1: Assign in _Ready
The cleanest fix is to declare the field uninitialized and assign it in _Ready:
public partial class Player : CharacterBody2D {
private HealthBar _hp;
public override void _Ready() {
_hp = GetNode<HealthBar>("UI/HealthBar");
}
public override void _Process(double delta) {
_hp.Value = health; // Works because _Ready has run
}
}
By the time _Process is invoked, Godot guarantees _Ready has completed for every node in the subtree in the correct order. You can rely on the field being set.
Fix 2: A Lazy @onready-Equivalent
If you want the GDScript @onready feeling — declare at the top, use below — add a small helper that lazy-loads on first access:
public partial class Player : CharacterBody2D {
private HealthBar _hp;
private HealthBar Hp => _hp ??= GetNode<HealthBar>("%HealthBar");
public override void _Process(double delta) {
Hp.Value = health;
}
}
The property reads null, calls GetNode, caches the result in _hp, and returns it. Subsequent accesses are a single null check. This also avoids an assignment in _Ready so you can forget to write one.
Fix 3: Use Unique Name References
Long node paths like "UI/HUD/StatusBar/HealthBar" break the moment an artist re-parents the node. Mark the target node with Access as Unique Name (right-click → toggle) in the scene tree and you get a %-prefixed shortcut:
_hp = GetNode<HealthBar>("%HealthBar"); // Uses scene-local unique lookup
Godot searches the entire scene (up to the scene boundary) for a node marked as unique with that name. If you re-parent the HealthBar under a new panel, the lookup still works. Unique names are scene-scoped, so two scenes can both have their own %HealthBar without conflict.
Fix 4: Null vs Freed
C# does not nullify references when the underlying Godot Node is freed with QueueFree. Your cached reference will still look non-null, but calling methods on it throws an ObjectDisposedException. Always check GodotObject.IsInstanceValid before using a cached reference that could have been freed elsewhere:
public void ApplyDamage(int dmg) {
if (_hp is not null && GodotObject.IsInstanceValid(_hp)) {
_hp.Value -= dmg;
}
}
For references that you expect to be long-lived, subscribe to the node’s TreeExiting signal and null out your cache when it fires. That way you never try to touch a freed node in the first place.
Fix 5: Export Nodes Instead of GetNode
Godot 4 supports [Export] private NodePath _hpPath; or even [Export] private HealthBar _hp;. The editor lets you drag-drop the target in the inspector, and the reference is resolved at instance time. This is more refactor-resilient than string paths and avoids runtime lookups entirely:
public partial class Player : CharacterBody2D {
[Export] private HealthBar _hp;
public override void _Process(double delta) {
_hp.Value = health; // Guaranteed populated if exporter is set
}
}
If you forget to drag the node into the inspector slot, the field is null — but the editor will warn you with a missing-reference indicator, and your team notices the issue at edit time rather than at runtime.
“In GDScript,
@onreadyhides the lifecycle. In C#, the lifecycle is your responsibility — so pick a pattern and apply it everywhere.”
Verifying the Fix
Add a breakpoint inside _Ready and one at the call site of the cached field. Inspect both: is the field populated between those moments? If yes, you have fixed the init race. If not, the lookup path itself is wrong — run PrintTreePretty() on the parent to see the actual hierarchy. Typos in path separators (\\ vs /) and capitalization mismatches are the most common culprits once timing is correct.
Related Issues
If signals emit but C# handlers do not fire, see Godot C# Signal Handler Not Called. For scene instantiation crashes, read PackedScene Instantiate Null After Reload.
_Ready + lazy property + unique names (%) + [Export] — four tools, zero null GetNodes.