Quick answer: preload() requires a string literal path starting with res://, resolved at parse time — not a variable, not a path relative to the current file. If the resource hasn’t been imported yet, the call will also fail. Use load() for dynamic paths, and ResourceLoader.exists() to validate paths before loading.

A preload() call that returns null or throws an import error is one of the most disorienting bugs in Godot, because it often works perfectly in one context and silently fails in another. The root cause is almost always one of three things: a path that isn’t a literal string, a path that isn’t rooted at res://, or a file that hasn’t been processed by Godot’s importer yet. Here’s how to diagnose and fix each case.

preload() vs load(): Understanding the Difference

preload() and load() both load a resource, but they operate at completely different times in the script lifecycle. preload() is resolved at parse time — when Godot reads and compiles your GDScript file, before any code actually runs. load() is resolved at runtime — when the line of code executes.

This distinction has a critical consequence: the argument to preload() must be a string literal. The GDScript parser needs to know the exact path at compile time so it can embed the resource reference into the compiled script. A variable, a concatenated string, or a function call result cannot be used because the parser doesn’t evaluate expressions at parse time.

# WRONG: preload() cannot use a variable as its argument
var path = "res://assets/player.png"
var texture = preload(path)  # Parse error!

# CORRECT: preload() requires a string literal
var texture = preload("res://assets/player.png")

# CORRECT: use load() for dynamic paths
var path = "res://assets/" + skin_name + ".png"
var texture = load(path)

If you need to load resources whose paths are computed at runtime — based on player choices, level data, or configuration files — load() is the correct tool. The tradeoff is that load() happens synchronously on the main thread when called, which can cause a brief stutter if the resource is large. For large assets, use ResourceLoader.load_threaded_request() to load in the background.

Paths Must Be Relative to the Project Root, Not the Script File

The most common cause of a preload() returning null or an error is a path that is accidentally relative to the current script file rather than the project root. Godot resource paths always start with res:// and are always relative to the project root directory (where project.godot lives), regardless of where the script file itself is located.

# This script is at: res://entities/player/player.gd
# The sprite is at:  res://entities/player/sprites/idle.png

# WRONG: attempting a relative path from the script file
var idle = preload("sprites/idle.png")  # null or error

# WRONG: attempting ../relative paths (not supported in res://)
var idle = preload("../player/sprites/idle.png")  # error

# CORRECT: always use the full res:// path from the project root
var idle = preload("res://entities/player/sprites/idle.png")

There is no concept of relative path resolution in Godot’s resource system. Every res:// path is absolute from the project root. If you find yourself typing ../../ to reach another file, that pattern doesn’t work — write out the full path from res://.

To quickly find the correct path for a file, right-click it in Godot’s FileSystem dock and choose Copy Path. This copies the full res:// path to your clipboard, ready to paste into a preload() call.

The Resource Hasn’t Been Imported Yet

When you add a file to your Godot project by copying it directly into the project folder (instead of dragging it into the FileSystem dock), Godot may not have imported it yet. Godot’s importer creates a companion .import file for each asset — this sidecar file contains the engine-ready processed form of the resource. Without it, preload() and load() will both fail.

The fix is to let the editor re-scan and import the file:

This is especially common when assets are added via version control (git pull, zip extraction) rather than through the editor itself. If your team uses git, commit the .import files alongside the assets — or add a project setup step that reimports on first open.

Using ResourceLoader.exists() to Validate Before Loading

ResourceLoader.exists() checks whether a resource path is valid and importable without actually loading it. This is useful for defensive checks in systems that load resources dynamically, or for diagnosing whether a path problem is the cause of a null result.

func safe_load_texture(path: String) -> Texture2D:
    if not ResourceLoader.exists(path):
        push_error("Resource not found or not imported: " + path)
        return null

    var resource = load(path)
    if resource == null:
        push_error("Resource loaded as null (wrong type?): " + path)
        return null

    return resource as Texture2D

Note that ResourceLoader.exists() can also accept a second argument specifying the expected type class name as a string. This lets you check not just that the path exists but that it resolves to the type you expect:

# Check that the path exists AND is a Texture2D
if ResourceLoader.exists("res://ui/icon.png", "Texture2D"):
    print("Path is valid and is a Texture2D")
else:
    push_warning("Path missing or wrong resource type")

The @export + preload Pattern for Inspector Assignment

A cleaner alternative to hardcoding paths with preload() is to use @export to expose the resource slot in the Inspector and assign the resource there. This decouples the resource reference from the code, making it easy to swap assets without touching the script.

extends Sprite2D

# Expose a texture slot in the Inspector
@export var idle_texture: Texture2D
@export var walk_texture: Texture2D

func _ready():
    # Use the Inspector-assigned texture directly
    texture = idle_texture

func set_walking(is_walking: bool):
    texture = walk_texture if is_walking else idle_texture

The @export pattern also works well with preload() as a default value, giving the Inspector slot a sensible default while still allowing overrides:

@export var icon: Texture2D = preload("res://ui/icons/default_icon.png")

This is the preferred pattern for any resource that might vary between instances of the same script. Hardcoded preload() paths tie every instance to the same asset; exported properties allow different scenes or nodes to use different assets with the same script.

Error Checking with load() vs preload()

preload() causes a parse-time error if the path is invalid — the script won’t even load. This is useful in a way, because you catch the problem immediately in the editor. But it also means you can’t gracefully handle the missing resource at runtime.

load() returns null if the resource cannot be found or loaded, and does not throw by itself. This gives you the opportunity to handle the failure gracefully:

var skin_path = "res://skins/" + current_skin + "/player.tres"
var skin: Resource = load(skin_path)

if skin == null:
    push_error("Failed to load skin: " + skin_path)
    # Fall back to the default skin
    skin = preload("res://skins/default/player.tres")

apply_skin(skin)

A common mistake is to cast the result of load() immediately without checking for null first. If load() returns null and you call a method on it, you get a null dereference error on the calling line, not on the load() line, which makes the bug harder to trace. Always check the return value of load() before using it.

“The difference between preload() failing silently and load() returning null is the difference between a parse-time guarantee and a runtime contract. Know which one you’re relying on.”

Checklist: When preload() Returns Null or Errors

When in doubt, right-click the file in the FileSystem dock and choose Copy Path — it’s the fastest way to get a valid res:// path.