Quick answer: preload() loads a resource at compile time (when the script is parsed), while load() loads it at runtime when the line executes.
Here is how to fix Godot preload vs load errors. Godot offers two ways to load resources in GDScript: preload() and load(). They look similar, but they operate at fundamentally different stages of your game’s lifecycle. Using the wrong one in the wrong context leads to errors that range from cryptic cyclic dependency crashes to silent performance problems. This guide explains when and why each function fails, and how to choose the right one.
The Symptom
The most dramatic failure is the cyclic dependency error. You have script A that preloads script B, and script B preloads script A. When you open the project or try to run the game, the editor shows an error like:
# Error: Cyclic dependency detected when trying to load
# "res://scripts/player.gd"
The project may refuse to parse the scripts entirely, leaving you with red error markers on multiple files. In severe cases, the editor freezes during project load as it enters an infinite resolution loop trying to preload the circular chain.
A subtler symptom is using preload() with a path that is constructed at runtime — a variable or a concatenated string. The editor shows a parse error because preload() requires a constant string literal. The error message may say something like “Expected constant expression” or “preload() argument must be a constant string.”
On the other end of the spectrum, you might use load() for everything and notice that your game hitches every time it loads a scene or resource, because load() performs disk I/O on the main thread at the moment it is called.
What Causes This
preload() runs at compile time. When the GDScript compiler parses your script, it encounters the preload() call and immediately loads the referenced resource into memory. This happens before any of your game code runs. The benefit is that the resource is instantly available at runtime with zero loading delay. The cost is that the compiler must be able to resolve the path and load the resource during parsing.
If script A preloads script B, the compiler pauses parsing A, switches to parsing B, and loads it. If B also preloads A, the compiler tries to parse A again — but A is not finished parsing yet. This creates a deadlock that the compiler detects and reports as a cyclic dependency error.
load() runs at runtime. It is a regular function call that happens when execution reaches that line of code. It reads the resource from disk (or from the resource cache if it was loaded before) and returns it. Because it happens at runtime, it can accept dynamic paths built from variables. But it also means every load() call is a potential frame hitch if the resource is large and not yet cached.
ResourceLoader provides a third option: threaded loading. ResourceLoader.load_threaded_request() starts loading a resource in a background thread, and you can poll for completion or wait for a callback. This avoids both the cyclic dependency problem of preload() and the frame-hitching problem of load().
The Fix
Step 1: Break cyclic preloads by switching one to load(). Identify which scripts form the dependency cycle. Change the less critical preload to a runtime load. Typically, the script that is used less frequently or later in the lifecycle should use load().
# player.gd — preloads the weapon scene (fine)
const WeaponScene = preload("res://scenes/weapon.tscn")
func _ready():
var weapon = WeaponScene.instantiate()
add_child(weapon)
# weapon.gd — uses load() instead of preload() to break cycle
var PlayerClass = null
func _ready():
# load() at runtime breaks the cyclic dependency
PlayerClass = load("res://scripts/player.gd")
var player = get_parent() as PlayerClass
if player:
player.register_weapon(self)
Step 2: Use preload() only with constant string paths. Never try to build a path dynamically and pass it to preload(). If you need dynamic paths, use load().
# Wrong — variable path in preload
var path = "res://enemies/" + enemy_name + ".tscn"
var scene = preload(path) # Parse error!
# Correct — use load() for dynamic paths
var path = "res://enemies/" + enemy_name + ".tscn"
var scene = load(path)
Step 3: Use ResourceLoader for large resources. When loading full scenes or heavy assets that might cause frame drops, use the threaded loading API.
# Start loading the next level in the background
func start_level_load(level_path: String):
ResourceLoader.load_threaded_request(level_path)
# Poll for completion each frame
func _process(delta):
var status = ResourceLoader.load_threaded_get_status(level_path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
var scene = ResourceLoader.load_threaded_get(level_path)
get_tree().change_scene_to_packed(scene)
Step 4: Prefer preload() for small, frequently used resources. For textures, sounds, small scenes, and scripts that you reference constantly, preload() is the right choice. It eliminates any runtime loading cost and the resource is guaranteed to be available when your code runs.
# Good use of preload — small, constant resources
const HitSound = preload("res://audio/hit.ogg")
const SparkParticle = preload("res://particles/spark.tscn")
const DamageNumber = preload("res://ui/damage_number.tscn")
Why This Works
The cyclic dependency error is a compile-time problem that only affects preload(). By switching one side of the cycle to load(), you defer that dependency to runtime, when both scripts have already been fully parsed. The compiler no longer needs to load the other script during parsing, so the cycle is broken.
Using load() for dynamic paths works because it is a regular function call. The path string does not need to exist at parse time — it only needs to be a valid resource path when the line executes at runtime. If the path is wrong, you get a runtime error (or a null return value) instead of a parse error.
ResourceLoader solves the performance problem by moving disk I/O to a background thread. Your main thread continues rendering frames while the resource loads. The status-polling pattern lets you show a loading bar or animation without freezing the game. This is the recommended approach for anything that takes more than a few milliseconds to load.
Rule of thumb: preload constants, load variables, thread-load levels.
Related Issues
Cyclic preload errors often involve class_name declarations. If you are seeing cycles after adding class_name to your scripts, read Fix: Godot class_name Causing Cyclic Reference Errors. For issues with variables disappearing after script reloads triggered by load(), see Fix: “Identifier not found” After Renaming a Variable in Godot. If your await calls are blocking because a resource load never completes, check Fix: Godot await Not Working or Blocking Forever.