Why Godot holds onto nodes you removed
A Node stays alive as long as two conditions are true: the underlying Object has not been deleted, and nothing has told the engine to delete it. Adding a node with add_child() creates an ownership link. The parent owns its children, so freeing the parent frees the whole subtree. Removing that link with remove_child() does the opposite. It detaches the node from the SceneTree but it does not delete it.
That detached node is what Godot calls an orphan. It is not in the tree, it does not process, and it does not render, but it still occupies memory. Its scripts, children, collision shapes, and any resources it uniquely owns stay allocated. Do this once for a bullet or a damage popup and you will never notice. Do it twice per second from a spawner and the Debugger object count climbs for the whole session.
This behavior is separate from RefCounted types like ArrayMesh or StreamTexture, which release themselves when the last reference goes away. Nodes inherit from Object, not RefCounted, so the engine waits for an explicit free() or queue_free(). That design solves a real problem: the tree needs stable ownership during physics, rendering, and signal emission. The consequence a developer feels is that ownership mistakes become slow memory growth instead of an immediate error.
What queue_free does that free does not
free() deletes the object immediately. queue_free() marks the node for deletion and lets the SceneTree delete it at the end of the current frame. That one frame delay is the whole safety mechanism.
| Call | When memory is released | Safe during signals and physics | What happens to the subtree |
|---|---|---|---|
free() | Right now | No, it can crash an in progress iteration | Children are freed immediately too |
queue_free() | End of frame | Yes, this is the expected path | Whole subtree is freed when the queued root is deleted |
Calling free() inside _on_body_entered, a signal handler, or _physics_process is risky because the caller may still hold a pointer to the node. The tree may be in the middle of traversing that same list of children. queue_free() avoids the problem by setting an internal queued flag. is_queued_for_deletion() returns true, but is_instance_valid(node) still returns true until the actual deletion runs.
Treat a queued node as already gone. Do not add it back to the tree, do not store it in a new array, and do not call methods on it after you queue it.
func _on_hitbox_body_entered(body: Node2D) -> void:
body.queue_free()
# Wrong: body is still valid this frame, but logic should stop here.
# Right: return early or guard the next call with is_instance_valid.This next part is inference from observable behavior, not a claim about source internals. The engine appears to keep a deletion queue and flush it between frames, which is why you need to wait one or two frames in tests before you can confirm that a node is truly gone.
How to prove a leak with print_orphan_nodes
Node.print_orphan_nodes() writes every still allocated orphan node to standard output. It is the fastest way to confirm a suspicion before you rewrite code. The pattern is simple: establish a baseline, run the suspect action many times, return to a quiet state, wait for the deletion queue to flush, then dump.
Here is one real worked example traced end to end. A shooter instantiates a bullet scene, adds it to a container, and detaches it on impact:
var BulletScene: PackedScene = preload("res://bullet.tscn")
@onready var bullet_root: Node2D = $BulletRoot
func _on_shoot() -> void:
var bullet: Node2D = BulletScene.instantiate()
bullet_root.add_child(bullet)
bullet.global_position = global_position
func _on_bullet_hit(bullet: Node2D) -> void:
bullet_root.remove_child(bullet)
# Bug: ownership is gone, but no one freed the bullet.After 5 minutes at two shots per second, that bug leaves about 600 orphan bullets. Each one keeps its Sprite2D, CollisionShape2D, and script state. The frame rate may still look fine, but node creation gets slower, scene changes take longer, and the Monitors tab for Nodes and Objects never returns to baseline.
Use this check to make the leak visible:
- Run the scene that contains the spawner in isolation.
- Shoot 100 times, let every bullet hit something, then stop shooting.
- Wait two idle frames so any correct
queue_free()calls can finish. - Call
Node.print_orphan_nodes()from a debug button or from_unhandled_input.
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("debug_dump"):
await get_tree().process_frame
await get_tree().process_frame
Node.print_orphan_nodes()The dump lists class names and instance counts. If you see 100 Bullet entries after the test, you have confirmed the leak. The fix is one line, plus a guard so a double hit does not queue twice:
func _on_bullet_hit(bullet: Node2D) -> void:
if is_instance_valid(bullet) and not bullet.is_queued_for_deletion():
bullet.queue_free()Do not call remove_child() first unless you plan to reuse the node. queue_free() on the subtree root already handles the children.
Cleanup patterns that prevent godot orphan nodes
Scene changes hide the same bug at a larger scale. change_scene_to_file() frees the current scene root, but it cannot free nodes you moved outside that root, nodes stored in an autoload array, or nodes created with .new() and never added or freed. If your autoload keeps direct node paths for a save system, store plain data like global position and health instead of storing the node itself.
These patterns cover most lingering references in GDScript memory management:
- Pair every
remove_child()with eitherqueue_free()or an explicit transfer to a new parent. A bareremove_child()should stand out in review. - Free the subtree root only. If an enemy owns its health bar, timer, and hitbox, queue the enemy and let the children follow.
- Clear containers on teardown. An
active_enemies: Arrayin a manager still points at queued nodes until you erase them or rebuild the list withis_instance_valid. - Be careful with lambdas. A Callable stored in an autoload that captures a node keeps that Callable alive, which makes the leak harder to trace even though the signal system itself cleans up connections to freed objects.
- Prefer
CONNECT_ONE_SHOTfor temporary UI and tutorial prompts so a forgottendisconnect()cannot keep spawning logic reachable. - For high churn objects like projectiles and particles, reuse a pool instead of constant instantiate and free. The pool still needs a fixed size and a clear reset step.
A fixed pool also helps performance. Constant allocation stresses the allocator and fragments caches, while reuse keeps the active set bounded and testable.
How to audit a large scene for lingering nodes
Opening every script in a 200 node level is slow. Run the game instead and let the orphan list point at the suspects.
- Pick one loop to test, such as enter arena, fight three waves, exit to menu.
- Note the Node and Object counts in Debugger, Monitors before the loop.
- Run the loop twice. The second run shows whether counts return to the same baseline.
- Dump orphans, copy the repeated class names, and search the project for where those scenes are instantiated.
- Fix the owner first, then the reference. Add the missing
godot queue_free, clear the holding array, and rerun the same loop.
When you work inside Godot MCP Studio, that same audit becomes a focused request. Instead of asking for a rewrite, follow the approach in asking it to unblock you: ask the agent to run the combat test, capture the orphan dump and monitor counts, and list which spawner scripts created the survivors. Because tool access changes AI game development, the agent can gather that evidence from the running project rather than guessing from file names.
A clean run has a clear signature. The orphan dump is empty, the second loop matches the first baseline, and no script needs remove_child() without a matching free or reparent. Check that signature after each milestone and a future godot memory leak stays a five minute fix instead of a release week hunt.