Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Game Development with Godot & GDScript  Module 3 – Arrays, dictionaries and packed data

Dictionaries and typed dictionaries

Store and look up game data in GDScript dictionaries with get, has and in, keep their order, merge them, declare typed dictionaries and copy nested data safely.

  • Beginner
  • 20 minutes
  • Examples run with godot 4.7.2-stable and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Store and look up game data in dictionaries with get, has and in, and handle a missing key
  • Iterate keys and values in insertion order, count with a dictionary and merge dictionaries with and without overwrite
  • Declare typed dictionaries such as Dictionary[String, int] (Godot 4.4 and later) and predict which check refuses a value
  • Copy nested dictionaries with duplicate(true) so that no inner dictionary or array is shared

Before you start

What you’ll learn · Before you start

What you will learn

  • Store and look up game data in dictionaries with get, has and in, and handle a missing key
  • Iterate keys and values in insertion order, count with a dictionary and merge dictionaries with and without overwrite
  • Declare typed dictionaries such as Dictionary[String, int] (Godot 4.4 and later) and predict which check refuses a value
  • Copy nested dictionaries with duplicate(true) so that no inner dictionary or array is shared

Before you start

On this page

A copied hero’s hit points were set to 60, and on Godot 4.7.2 the original hero dropped to 60 hit points as well: duplicate() had copied the outer dictionary and left the stats inside it shared. Dictionaries hold the named data of a game, its stats, prices, settings and counts, and they share and copy by the same rules as arrays, with a few of their own.

Below you look values up by key and deal with keys that are not there, rely on the order entries were added in, count with a dictionary, choose the right way to merge two, give a dictionary key and value types that Godot holds it to, and copy nested data so that no inner dictionary is shared. What Godot 4.7.2 printed for each program is on the page beside its code.

A dictionary finds a value by its key

A dictionary: A collection of entries, each a value stored under a unique key, such as {"hp": 100}. A lookup by key finds the value, and a GDScript Dictionary keeps its entries in the order they were added. stores values under keys: {"hp": 100, "atk": 12, "def": 5} has three entries, and stats["hp"] follows the key "hp" to its value, 100. Keys are unique, and any value can be one, a String, an int, a Vector2, though Strings are the usual choice for named data. When a key is a fixed name, stats.hp is a shorter way to write stats["hp"]. Writing to a key replaces its value, and writing to a new key adds an entry. Step through the lookups of lookups.gd:

Interactive · step by step Reading and writing stats by key Step through the lines of lookups.gd. Each key on the left leads to its value on the right; a lookup follows the arrow of its key.
Reading and writing stats by key, step 6 of 6. Showing "hp", "atk", "def", "spd", "luck", 100, 12 (15), 5, 7. In focus: "hp", 100."hp""atk""def""spd""luck"not a key100121557

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Three entries. stats starts with three entries. Each key is unique and leads to one value; the order of the entries is the order they were written.
  2. stats["hp"]. stats["hp"] follows the key "hp" to its value and gives 100. stats.hp is a shorter way to write the same lookup for a key that is a fixed name.
  3. Two writes. Writing to a key that exists replaces its value: stats.atk = 15. Writing to a new key adds an entry at the end: stats["spd"] = 7.
  4. get("luck", 0). There is no key "luck". get() returns the default you give it, 0, and without a default it returns null. Nothing is added to stats.
  5. stats["luck"]. Square brackets on a missing key are an error: Godot stopped read_luck() with Invalid access to property or key luck. Use get() or has() when a key may be missing.
  6. in looks at keys. "hp" in stats is true and 100 in stats is false, although 100 is a value: in and has() only look at the keys.

lookups.gd: keys, get(), has(), in and the types of keys GD · lookups.gd
extends SceneTree

# Looking values up by key, and what happens when the key is not there.
func _initialize() -> void:
	var stats := {"hp": 100, "atk": 12, "def": 5}
	print("stats[\"hp\"]: ", stats["hp"], "  stats.hp: ", stats.hp)
	stats["spd"] = 7
	stats.atk = 15
	print("after two writes: ", stats)
	print("get(\"luck\", 0): ", stats.get("luck", 0), "  get(\"luck\"): ", stats.get("luck"))
	print("has(\"hp\"): ", stats.has("hp"), "  \"hp\" in stats: ", "hp" in stats, "  100 in stats: ", 100 in stats)
	read_luck(stats)

	var slime := {hp = 10, name = "slime"}
	print("Lua-style: ", slime, "  has(\"hp\"): ", slime.has("hp"))
	var actions := {"jump": 1}
	print("String key, StringName lookup: ", actions.has(&"jump"))
	var ids := {1: "int", 1.0: "float", "1": "string"}
	print(ids.size(), " keys: ", ids, "  ids[1]: ", ids[1], "  ids[1.0]: ", ids[1.0])

	var target := {"enemy": null}
	print("has(\"enemy\"): ", target.has("enemy"), "  get(\"enemy\", \"none\"): ", target.get("enemy", "none"))
	quit()


func read_luck(stats: Dictionary) -> void:
	print("stats[\"luck\"]: ", stats["luck"])
Looking values up by key, Godot 4.7.2 Python · lookups_session.py
from session import files, run

files("project.godot", "lookups.gd")
run("""
!godot --headless --no-header --script lookups.gd
""")

Output

$ godot --headless --no-header --script lookups.gd
stats["hp"]: 100  stats.hp: 100
after two writes: { "hp": 100, "atk": 15, "def": 5, "spd": 7 }
get("luck", 0): 0  get("luck"): <null>
has("hp"): true  "hp" in stats: true  100 in stats: false
SCRIPT ERROR: Invalid access to property or key 'luck' on a base object of type 'Dictionary'.
          at: read_luck (res://lookups.gd:27)
          GDScript backtrace (most recent call first):
              [0] read_luck (res://lookups.gd:27)
              [1] _initialize (res://lookups.gd:12)
Lua-style: { &"hp": 10, &"name": "slime" }  has("hp"): true
String key, StringName lookup: true
3 keys: { 1: "int", 1.0: "float", "1": "string" }  ids[1]: int  ids[1.0]: float
has("enemy"): true  get("enemy", "none"): <null>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lookups_session.py

A missing key is the case to plan for. stats["luck"] stopped read_luck() with Invalid access to property or key 'luck', while stats.get("luck", 0) returned the default, 0, and get("luck") without one returned null. has() and in say whether a key is there, and they look only at keys: 100 in stats was false although 100 is a value.

Keys are compared by value and by type. {1: "int", 1.0: "float", "1": "string"} held three different keys, so a lookup with the int 1 never finds an entry stored under the String "1". String and StringName: A built-in type for names, written &"jump". Each value is stored once, so two StringNames with the same text are the same object and compare very fast. Node names and input actions are StringNames. More in “Strings, StringName and text formatting” keys are the exception: {"jump": 1}.has(&"jump") printed true. The Writing a dictionary with = and bare names instead of : and quoted keys, as in {hp = 10, atk = 12}. The keys must be names that could be identifiers. {hp = 10, name = "slime"} saves the quotes; its keys printed as &"hp" and &"name", StringNames, and still answer to has("hp").

Using get() to tell a missing key from a null value

{"enemy": null}.get("enemy", "none") returned null, not “none”: the key is there, and its value is null. When a key may hold null, ask has("enemy") first; get() gives the default only when the key is missing.

Entries stay in the order they were added

A loop over a dictionary gets its keys, in the order the entries were added; keys() and values() return them as arrays in the same order. The class reference promises it: “Dictionaries will preserve the insertion order when adding new entries.” Erasing a key and writing it again puts it at the end, so atk moved behind def in the run. That order makes a dictionary a good counter: counts[drop] = counts.get(drop, 0) + 1 adds a key the first time an item drops and raises its count every time after. Step through six drops:

Interactive · step by step Counting drops with get(drop, 0) + 1 Step through the six drops. Each one raises the count of its key, or adds the key at the end of counts when it is new.
Counting drops with get(drop, 0) + 1, step 6 of 6. Showing drops[0], drops[1], drops[2], drops[3], drops[4], drops[5], counts["gem"] (3), counts["coin"] (2), counts["key"]. In focus: drops[5], counts["gem"].drops[0]gemdrops[1]coindrops[2]gemdrops[3]keydrops[4]coindrops[5]gemcounts["gem"]3counts["coin"]2counts["key"]1

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The first gem. counts is empty, so counts.get("gem", 0) gives 0, and 0 + 1 is stored under the new key "gem", the first entry.
  2. A new key, coin. coin is new too, so it gets the count 1 and becomes the second entry. New keys always go to the end.
  3. The second gem. gem is there now, so get() returns its 1, and 1 + 1 replaces it. The order of the entries does not change.
  4. A key. key is new: count 1, third entry.
  5. Coin again. coin goes up to 2.
  6. Gem again. gem goes up to 3. The run printed counts as { "gem": 3, "coin": 2, "key": 1 }, in the order each item first dropped.

drop_counts.gd: order, counting, sort() and == GD · drop_counts.gd
extends SceneTree

# Entries keep the order they were added in: loops, erase, counting, sorting and ==.
func _initialize() -> void:
	var stats := {"hp": 100, "atk": 12, "def": 5}
	for stat in stats:
		print(stat, " = ", stats[stat])
	print("keys(): ", stats.keys(), "  values(): ", stats.values())
	stats.erase("atk")
	stats["atk"] = 15
	print("after erase(\"atk\") and stats[\"atk\"] = 15: ", stats)

	var drops := ["gem", "coin", "gem", "key", "coin", "gem"]
	var counts := {}
	for drop in drops:
		counts[drop] = counts.get(drop, 0) + 1
	print("counts: ", counts)
	for item: String in counts:
		print("  ", item, " x", counts[item])

	var by_name := counts.duplicate()
	by_name.sort()
	print("sort(): ", by_name)
	print("counts == by_name: ", counts == by_name)
	print("find_key(2): ", counts.find_key(2), "  find_key(9): ", counts.find_key(9))
	quit()
Entries in the order they were added, Godot 4.7.2 Python · order_session.py
from session import files, run

files("project.godot", "drop_counts.gd")
run("""
godot --headless --no-header --script drop_counts.gd
""")

Output

$ godot --headless --no-header --script drop_counts.gd
hp = 100
atk = 12
def = 5
keys(): ["hp", "atk", "def"]  values(): [100, 12, 5]
after erase("atk") and stats["atk"] = 15: { "hp": 100, "def": 5, "atk": 15 }
counts: { "gem": 3, "coin": 2, "key": 1 }
  gem x3
  coin x2
  key x1
sort(): { "coin": 2, "gem": 3, "key": 1 }
counts == by_name: true
find_key(2): coin  find_key(9): <null>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 order_session.py

The counts came out as { "gem": 3, "coin": 2, "key": 1 }, the order in which each item first dropped, and for item: String in counts gave the loop variable the type of the keys. sort() reorders the entries by key, coin before gem before key, and counts == by_name was still true: == compares the keys and values and, in the words of the class reference, “The order of the entries does not matter.” find_key(2) goes the other way, from a value to the first key that holds it, and returns null when no key does.

Key idea

A dictionary keeps its entries in the order they were added, so a loop, keys() and a printout all show that order, while == ignores it.

Erasing entries of a dictionary while a loop reads it is not supported; loop over keys(), a separate array, and erase from the dictionary itself, as conditions and loops showed.

Checkpoint

Check: Keys, get() and missing keys · Order, counting and ==

  1. Question 1 of 3 prices is {"potion": 25}. Which line reads the price of a bow without stopping the function when there is none?

    Choose one answer.

    A hint

    One of them gives a default when the key is missing.

    Show the answer: Question 1 of 3

    Answer: var cost = prices.get("bow", 0)

    get(key, default) for a key that may be missing; [ ] or the dot only where the key must be there.

  2. Question 2 of 3 A counter stores kills under enemy ids read from text, such as "7". Later code asks kills.get(7, 0) and always gets 0. Why?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: The keys are the Strings "7", and the int 7 is a different key

    Convert the keys once, when the data comes in, for example with int(), so every lookup uses the same type. String and StringName are the one pair of types that match each other.

  3. Question 3 of 3 What does counts hold after counting the drops ["key", "gem", "key"] with counts[d] = counts.get(d, 0) + 1?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: { "key": 2, "gem": 1 }

    The run counted six drops into { "gem": 3, "coin": 2, "key": 1 }, in the order each item first dropped.

merge() keeps what is there unless you ask it to overwrite

A hero’s base stats and a sword’s stats have to become one set. merge() copies the entries of another dictionary into the one it is called on; merged() does the same into a new dictionary and leaves both alone. For a key that both have, the second argument decides: without it, or with false, the value already there stays, and with true the value merged in replaces it.

stats_merge.gd: base stats and a sword, merged four ways GD · stats_merge.gd
extends SceneTree

# A hero's base stats and a sword's: merged without and with overwrite.
const DEFAULTS := {"volume": 80, "subtitles": false, "language": "en"}


func _initialize() -> void:
	var base := {"hp": 100, "atk": 12, "res": {"fire": 10, "ice": 0}}
	var sword := {"atk": 20, "crit": 0.1, "res": {"ice": 25}}
	print("base.merged(sword):       ", base.merged(sword))
	print("base.merged(sword, true): ", base.merged(sword, true))
	var armed := base.merged(sword, true)
	print("is_same(armed.res, sword.res): ", is_same(armed.res, sword.res))
	print("base after merged(): ", base)
	base.merge(sword)
	print("base after base.merge(sword): ", base)

	var saved := {"volume": 35}
	var settings := DEFAULTS.merged(saved, true)
	settings["subtitles"] = true
	print("settings: ", settings, "  read-only: ", settings.is_read_only())
	print("DEFAULTS: ", DEFAULTS, "  read-only: ", DEFAULTS.is_read_only())
	DEFAULTS.merge(saved, true)
	print("DEFAULTS after DEFAULTS.merge(saved, true): ", DEFAULTS)
	quit()
Merging with and without overwrite, Godot 4.7.2 Python · merge_session.py
from session import files, run

files("project.godot", "stats_merge.gd")
run("""
!godot --headless --no-header --script stats_merge.gd
""")

Output

$ godot --headless --no-header --script stats_merge.gd
base.merged(sword):       { "hp": 100, "atk": 12, "res": { "fire": 10, "ice": 0 }, "crit": 0.1 }
base.merged(sword, true): { "hp": 100, "atk": 20, "res": { "ice": 25 }, "crit": 0.1 }
is_same(armed.res, sword.res): true
base after merged(): { "hp": 100, "atk": 12, "res": { "fire": 10, "ice": 0 } }
base after base.merge(sword): { "hp": 100, "atk": 12, "res": { "fire": 10, "ice": 0 }, "crit": 0.1 }
settings: { "volume": 35, "subtitles": true, "language": "en" }  read-only: false
DEFAULTS: { "volume": 80, "subtitles": false, "language": "en" }  read-only: true
ERROR: Dictionary is in read-only state.
   at: merge (core/variant/dictionary.cpp:333)
   GDScript backtrace (most recent call first):
       [0] _initialize (res://stats_merge.gd:23)
DEFAULTS after DEFAULTS.merge(saved, true): { "volume": 80, "subtitles": false, "language": "en" }

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 merge_session.py

base.merged(sword) kept the base’s atk of 12 and added crit; with true the sword’s 20 won. Look at res: merging does not reach inside a nested dictionary. With overwrite the sword’s whole {"ice": 25} replaced the base’s {"fire": 10, "ice": 0}, so the fire resistance was lost, and is_same(armed.res, sword.res) printed true: the result holds the sword’s own dictionary, not a copy. Two questions about your case decide which call fits; answer them in the table:

Interactive · answer and compare Which merge fits your case? Answer the two questions about the dictionaries you combine; each answer rules out the calls that would do the wrong thing.
Must the dictionary you start from stay as it was?
When both have a key, whose value should win?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four ways to combine base and sword, against what they change and which value wins
Call base afterwards A key in both A nested dictionary
merge() Called as: base.merge(sword) Poor changed in place: crit was added to base Mixed base keeps its atk 12 Mixed base keeps its res
merge(…, true) Called as: base.merge(sword, true) Poor changed in place Mixed the sword's atk 20 replaces 12 Poor res becomes the sword's ice 25; fire 10 is lost
merged() Called as: base.merged(sword) Good unchanged; a new dictionary comes back Mixed base's atk 12 stays Mixed base's res stays, whole
merged(…, true) Called as: base.merged(sword, true) Good unchanged; a new dictionary comes back Mixed the sword's atk 20 wins Poor res is the sword's own dictionary, shared
How the answers decide
  • “Must the dictionary you start from stay as it was?” Yes, it is used again: rules out merge() and merge(…, true). merge() changes the dictionary it is called on; merged() leaves it alone and returns a new one.
  • “When both have a key, whose value should win?” The one I start from: rules out merge(…, true) and merged(…, true). With overwrite true the value merged in replaces yours, as the sword's atk 20 replaced 12.
  • “When both have a key, whose value should win?” The one I merge in: rules out merge() and merged(). Without overwrite a key that is already there keeps its value, so the sword's atk would be ignored.
  • “When both have a key, whose value should win?” The one I merge in: adds a caveat to merge(…, true) and merged(…, true). A nested dictionary is replaced whole, not merged, and the result shares it: merge nested data yourself.

Settings show the usual case. The defaults must survive, and a value the player saved must win, so DEFAULTS.merged(saved, true) gave { "volume": 35, "subtitles": true, "language": "en" } once the player’s change of subtitles was written in, and DEFAULTS itself stayed as it was. A const dictionary is read-only: the run printed read-only: true for DEFAULTS, and DEFAULTS.merge(saved, true) failed with Dictionary is in read-only state. instead of quietly changing the defaults.

Merging nested stats

To add resistances key by key, merge the nested dictionaries yourself: copy the base with duplicate(true), then armed.res.merge(sword.res, true). The practice task below adds nested values the same way.

Checkpoint

Check: merge(), merged() and overwrite

  1. Question 1 of 2 base is {"atk": 12} and sword is {"atk": 20, "crit": 2}. What does base.merged(sword) return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: { "atk": 12, "crit": 2 }

    The run printed base.merged(sword) with atk 12 and base.merged(sword, true) with atk 20.

  2. Question 2 of 2 base.res is {"fire": 10} and sword.res is {"ice": 25}. After armed := base.merged(sword, true), what is armed.res?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: { "ice": 25 }, the sword's own dictionary

    To combine resistances key by key, deep-copy the base and merge the res dictionaries themselves.

A typed dictionary checks its keys and values

Since Godot 4.4 a dictionary can name the types of its keys and values: var prices: Dictionary[String, int] is a typed dictionary: A dictionary declared with a key type and a value type, such as Dictionary[String, int], in Godot 4.4 and later. It refuses keys and values it cannot convert to those types, and its methods still return Variant. that takes only String keys and int values. Both types must be given, and Variant leaves a side open, as in Dictionary[String, Variant]. A typed dictionary is checked like a typed array: the parser refuses what it can see before the run, and the dictionary itself refuses wrong keys and values when the line runs, also through an untyped name, because the types belong to the dictionary. Typed collections came to GDScript one release at a time, so check the release a project needs:

Timeline · 4 events in order Typed collections, release by release Walk through the releases. Code that uses a feature needs at least the release that brought it.
  1. 4.0

    Typed arrays new syntax

    Array[int] and Array[Node] check the values put into them. A dictionary could hold any keys and values.

  2. 4.2

    Typed loop variables new syntax

    for item: String in prices gives the loop variable a type, for arrays and for the keys of a dictionary.

  3. 4.4

    Typed dictionaries new syntax

    Dictionary[String, int] names a key type and a value type; Variant leaves either side open. The Inspector edits them with the right types.

  4. 4.7

    The release the examples ran on

    Godot 4.7.2 ran every example here. Nested typed collections, such as Dictionary[String, Array[String]], were still refused.

The timeline has 4 events, from 4.0 to 4.7.

typed_prices.gd: a typed dictionary of shop prices at run time GD · typed_prices.gd
extends SceneTree

# A typed dictionary of shop prices: what it takes, converts and refuses while the game runs.
var prices: Dictionary[String, int] = {"potion": 25, "arrow": 2}


func _initialize() -> void:
	print("prices: ", prices, "  is_typed(): ", prices.is_typed())
	for item: String in prices:
		print("  ", item, " costs ", prices[item])
	var loose: Dictionary = prices
	print("loose.is_typed(): ", loose.is_typed())
	loose["shield"] = 40.9
	print("after loose[\"shield\"] = 40.9: ", prices)
	print("loose.set(\"bomb\", \"cheap\") returned ", loose.set("bomb", "cheap"))
	price_as_text()
	print("after price_as_text(): ", prices)

	var stock: Dictionary[String, Variant] = {"name": "the corner shop", "open": true, "items": 3}
	print("Dictionary[String, Variant]: ", stock)
	var sale: Dictionary[String, int] = {}
	sale.assign({"potion": 20, "arrow": 1.5})
	print("assign() converted: ", sale)
	var cost: int = prices.get("bow", 0)
	print("cost: int = prices.get(\"bow\", 0): ", cost)
	take_plain({"potion": 25})
	quit()


func price_as_text() -> void:
	var loose: Dictionary = prices
	loose["bomb"] = "cheap"
	print("never printed")


func take_plain(plain: Dictionary) -> void:
	var typed: Dictionary[String, int] = plain
	print("never printed: ", typed)
typed_mistakes.gd: four lines refused before the run GD · typed_mistakes.gd
extends SceneTree

# Four lines that typed dictionaries refuse before the game runs.
func _initialize() -> void:
	var prices: Dictionary[String, int] = {"potion": "cheap"}
	prices[7] = 3
	prices["bow"] = "dear"
	var cost := prices.get("bow", 0)
	quit()
nested_typed.gd: typed arrays inside a typed dictionary GD · nested_typed.gd
extends SceneTree

# A typed dictionary whose values are typed arrays: the waves of each level.
var waves: Dictionary[String, Array[String]] = {}


func _initialize() -> void:
	quit()
Typed dictionaries at run time and before it, Godot 4.7.2 Python · typed_session.py
from session import files, run

files("project.godot", "typed_prices.gd", "typed_mistakes.gd", "nested_typed.gd")
run("""
!godot --headless --no-header --script typed_prices.gd
!godot --headless --no-header --check-only --script typed_mistakes.gd
!godot --headless --no-header --check-only --script nested_typed.gd
""")

Output

$ godot --headless --no-header --script typed_prices.gd
prices: { "potion": 25, "arrow": 2 }  is_typed(): true
  potion costs 25
  arrow costs 2
loose.is_typed(): true
after loose["shield"] = 40.9: { "potion": 25, "arrow": 2, "shield": 40 }
ERROR: Attempted to set a variable of type 'String' into a TypedDictionary.Value of type 'int'.
   at: _internal_validate (./core/variant/container_type_validate.h:73)
   GDScript backtrace (most recent call first):
       [0] _initialize (res://typed_prices.gd:15)
ERROR: Condition "!_p->typed_value.validate(value, "set")" is true. Returning: false
   at: set (core/variant/dictionary.cpp:207)
   GDScript backtrace (most recent call first):
       [0] _initialize (res://typed_prices.gd:15)
loose.set("bomb", "cheap") returned false
ERROR: Attempted to set a variable of type 'String' into a TypedDictionary.Value of type 'int'.
   at: _internal_validate (./core/variant/container_type_validate.h:73)
   GDScript backtrace (most recent call first):
       [0] price_as_text (res://typed_prices.gd:32)
       [1] _initialize (res://typed_prices.gd:16)
ERROR: Condition "!_p->typed_value.validate(value, "set")" is true. Returning: false
   at: set (core/variant/dictionary.cpp:207)
   GDScript backtrace (most recent call first):
       [0] price_as_text (res://typed_prices.gd:32)
       [1] _initialize (res://typed_prices.gd:16)
SCRIPT ERROR: Invalid assignment of property or key 'bomb' with value of type 'String' on a base object of type 'Dictionary[String, int]'.
          at: price_as_text (res://typed_prices.gd:32)
          GDScript backtrace (most recent call first):
              [0] price_as_text (res://typed_prices.gd:32)
              [1] _initialize (res://typed_prices.gd:16)
after price_as_text(): { "potion": 25, "arrow": 2, "shield": 40 }
Dictionary[String, Variant]: { "name": "the corner shop", "open": true, "items": 3 }
assign() converted: { "potion": 20, "arrow": 1 }
cost: int = prices.get("bow", 0): 0
SCRIPT ERROR: Trying to assign a dictionary of type "Dictionary" to a variable of type "Dictionary[String, int]".
          at: take_plain (res://typed_prices.gd:37)
          GDScript backtrace (most recent call first):
              [0] take_plain (res://typed_prices.gd:37)
              [1] _initialize (res://typed_prices.gd:26)
$ godot --headless --no-header --check-only --script typed_mistakes.gd
SCRIPT ERROR: Parse Error: Cannot include a value of type "String" as "int".
          at: GDScript::reload (res://typed_mistakes.gd:5)
SCRIPT ERROR: Parse Error: Cannot have a value of type "String" in a dictionary of type "Dictionary[String, int]".
          at: GDScript::reload (res://typed_mistakes.gd:5)
SCRIPT ERROR: Parse Error: Invalid index type "int" for a base of type "Dictionary[String, int]".
          at: GDScript::reload (res://typed_mistakes.gd:6)
SCRIPT ERROR: Parse Error: Cannot assign a value of type "String" as "int".
          at: GDScript::reload (res://typed_mistakes.gd:7)
SCRIPT ERROR: Parse Error: Value of type "String" cannot be assigned to a variable of type "int".
          at: GDScript::reload (res://typed_mistakes.gd:7)
SCRIPT ERROR: Parse Error: The variable type is being inferred from a Variant value, so it will be typed as Variant. (Warning treated as error.)
          at: GDScript::reload (res://typed_mistakes.gd:8)
ERROR: Failed to load script "res://typed_mistakes.gd" with error "Parse error".
   at: load (modules/gdscript/gdscript_resource_format.cpp:46)
$ godot --headless --no-header --check-only --script nested_typed.gd
SCRIPT ERROR: Parse Error: Nested typed collections are not supported.
          at: GDScript::reload (res://nested_typed.gd:4)
ERROR: Failed to load script "res://nested_typed.gd" with error "Parse error".
   at: load (modules/gdscript/gdscript_resource_format.cpp:46)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typed_session.py

What Godot did with each line, from the run above:

The line What Godot did
{"potion": "cheap"} for a Dictionary[String, int] refused it before the run: Cannot have a value of type "String" in a dictionary of type "Dictionary[String, int]".
prices[7] = 3 refused it before the run: Invalid index type "int" for a base of type "Dictionary[String, int]".
var cost := prices.get("bow", 0) refused it before the run: The variable type is being inferred from a Variant value, so it will be typed as Variant.
Dictionary[String, Array[String]] refused it before the run: Nested typed collections are not supported.
loose["bomb"] = "cheap" refused it when the line ran and stopped the function; prices kept its entries
loose["shield"] = 40.9 stored it as 40

get() still returns a Variant, as the class reference notes for every method of a typed dictionary, so := cannot take a type from it; write the type yourself, var cost: int = prices.get("bow", 0). loose.set("bomb", "cheap") returned false instead of stopping the function, which suits code that wants to react to a refusal. An untyped dictionary cannot be assigned to a typed variable: take_plain() stopped with Trying to assign a dictionary of type "Dictionary" to a variable of type "Dictionary[String, int]", and assign() is the way to copy its entries in, converting 1.5 to 1 on the way.

New in Godot 4.4

Typed dictionaries arrived in Godot 4.4, whose release page sums them up as “Keys required”. Projects for Godot 4.3 and earlier have untyped dictionaries only.

Checkpoint

Check: What a typed dictionary refuses

  1. Question 1 of 2 prices is a Dictionary[String, int]. What does prices["shield"] = 40.9 store, if it runs?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 40

    Round on purpose, with roundi(), before you store a float in an int dictionary.

  2. Question 2 of 2 prices is a Dictionary[String, int]. Which line loads without a parse error?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: var cost: int = prices.get("bow", 0)

    The methods of a typed dictionary still return Variant, as the class reference says, so := has no type to take.

Assignment shares a dictionary, and a deep copy duplicates the ones inside

Dictionaries are passed by reference: Shared, not copied: an assignment or a call gives the same array, dictionary, packed array or object another name, so a change made through any of the names shows through all of them. More in “Arrays and typed arrays”, like arrays: var leader := asha is a second name for the same dictionary, and a function that changes its parameter changes the caller’s dictionary. A dictionary inside a dictionary is shared the same way, and that is where copies go wrong. Step through one hero’s data and two copies of it, one made with duplicate() and one with duplicate(true):

Interactive · step by step One hero, a shallow copy and a deep copy Step through hero_copies.gd. A name on the left points at a dictionary on the right, and a dictionary's stats entry points at the stats it holds.
One hero, a shallow copy and a deep copy, step 7 of 7. Showing asha, leader, ravi, mira, Asha's data (name Asha, level 6), stats (hp 60, atk 12), Ravi's data (name Ravi, level 6), Mira's data (name Mira, level 6), Mira's stats (hp 60, atk 30). In focus: Mira's stats.ashaleadervar leader := asharaviasha.duplicate()miraasha.duplicate(true)Asha's dataname Asha, level 6statshp 60, atk 12Ravi's dataname Ravi, level 6Mira's dataname Mira, level 6Mira's statshp 60, atk 30

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. One dictionary. asha holds a dictionary whose stats entry is a second, nested dictionary.
  2. leader := asha. leader is another name for the same dictionary, so leader.level = 5 is seen through asha: the run printed 5.
  3. level_up(asha). The function gets the same dictionary as its parameter. It raised level to 6 and hp to 110, and both changes stayed after it returned.
  4. A shallow copy. asha.duplicate() makes a new outer dictionary for ravi, but its stats entry points at the same stats as asha's.
  5. Ravi changes. ravi.name = "Ravi" changed only the copy. ravi.stats.hp = 60 changed the shared stats, so asha's hp fell to 60 as well.
  6. A deep copy. asha.duplicate(true) copies the nested stats too, so mira points at stats of her own.
  7. Mira changes. mira.stats.atk = 30 changed only Mira's stats; asha still had atk 12. is_same(asha.stats, mira.stats) printed false, and for ravi it printed true.

hero_copies.gd: two names, a shallow copy and a deep copy GD · hero_copies.gd
extends SceneTree

# One hero's data under two names, a function that changes it, a shallow copy and a deep one.
func level_up(hero: Dictionary) -> void:
	hero.level += 1
	hero.stats.hp += 10


func _initialize() -> void:
	var asha := {"name": "Asha", "level": 1, "stats": {"hp": 100, "atk": 12}}
	var leader := asha
	leader.level = 5
	print("asha.level after leader.level = 5: ", asha.level)
	level_up(asha)
	print("after level_up(asha): ", asha)

	var ravi := asha.duplicate()
	ravi.name = "Ravi"
	ravi.stats.hp = 60
	print("asha after ravi, a shallow copy, changed: ", asha)
	var mira := asha.duplicate(true)
	mira.name = "Mira"
	mira.stats.atk = 30
	print("asha after mira, a deep copy, changed:    ", asha)
	print("mira: ", mira)
	print("is_same(asha.stats, ravi.stats): ", is_same(asha.stats, ravi.stats),
		"  is_same(asha.stats, mira.stats): ", is_same(asha.stats, mira.stats))
	quit()
Shared and copied dictionaries, Godot 4.7.2 Python · copies_session.py
from session import files, run

files("project.godot", "hero_copies.gd")
run("""
godot --headless --no-header --script hero_copies.gd
""")

Output

$ godot --headless --no-header --script hero_copies.gd
asha.level after leader.level = 5: 5
after level_up(asha): { "name": "Asha", "level": 6, "stats": { "hp": 110, "atk": 12 } }
asha after ravi, a shallow copy, changed: { "name": "Asha", "level": 6, "stats": { "hp": 60, "atk": 12 } }
asha after mira, a deep copy, changed:    { "name": "Asha", "level": 6, "stats": { "hp": 60, "atk": 12 } }
mira: { "name": "Mira", "level": 6, "stats": { "hp": 60, "atk": 30 } }
is_same(asha.stats, ravi.stats): true  is_same(asha.stats, mira.stats): false

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 copies_session.py

duplicate() is a shallow copy: A copy of an array or dictionary whose inner arrays and dictionaries are still shared with the original. duplicate() makes one. More in “Arrays and typed arrays”: ravi got a new outer dictionary, so his name changed alone, but his stats were Asha’s, and ravi.stats.hp = 60 took Asha down to 60 too. duplicate(true) is a deep copy: A copy of an array or dictionary in which every inner array and dictionary is copied too, so nothing is shared with the original. duplicate(true) makes one; Resources inside it stay shared. More in “Arrays and typed arrays”: it copies every nested dictionary and array, and Mira’s attack of 30 stayed hers. A Resource inside stays shared even then, the class reference notes. Use the deep copy whenever the data nests and the copy will be changed: a hero made from a template, a save slot copied before editing, the starting state of a level.

Key idea

=, parameters and merged() share nested dictionaries; when a copy will be changed, make it with duplicate(true), or is_same() will show the inner data is still one object.

Checkpoint

Check: Shared and copied dictionaries

  1. Question 1 of 2 hero is {"stats": {"hp": 100}} and clone := hero.duplicate(). What does clone.stats.hp = 1 do to hero?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: hero.stats.hp becomes 1 too

    duplicate(true) copies the nested dictionaries too: Mira's atk 30 stayed hers.

  2. Question 2 of 2 Enemies are made from a template dictionary with nested stats, and each enemy loses hit points on its own. Which line makes a new enemy?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: var enemy := TEMPLATE.duplicate(true)

    When data nests and the copy will change, copy it with duplicate(true).

Practise: total the drops and apply the modifiers

loot.gd, below, asks for two functions for a loot screen. total_drops() adds up a fight’s drops into a Dictionary[String, int], in the order the items first dropped, and apply_modifiers() adds each modifier to a copy of a hero’s base stats, nested resistances included. Neither may change what it is given.

Copy loot.gd and check_loot.gd into a folder with an empty project.godot, fill in both functions and start godot --headless --no-header --script check_loot.gd from that folder.

loot.gd: the starter file GD · practice/loot.gd
extends Node

# Your task: two functions for a loot screen. Neither may change what it is given.


# Adds up the drops of a fight. Each event is a dictionary such as {"item": "gem", "count": 2}; the result maps each
# item to its total, in the order the items first dropped: two gems, five coins and one gem give {"gem": 3, "coin": 5}.
func total_drops(events: Array[Dictionary]) -> Dictionary[String, int]:
	var totals: Dictionary[String, int] = {}
	return totals  # replace this line


# The base stats with every modifier added in turn. A modifier such as {"atk": 5} adds 5 to "atk" (a stat that is not
# there yet starts at 0). A nested dictionary adds into the nested stats of that name the same way:
# {"res": {"fire": 20}} adds 20 to base["res"]["fire"].
func apply_modifiers(base: Dictionary, mods: Array[Dictionary]) -> Dictionary:
	return base  # replace this line
check_loot.gd: the checker of this task GD · practice/check_loot.gd
extends SceneTree

# Checks loot.gd. Run it in the project's folder: godot --headless --no-header --script check_loot.gd
var passed := 0
var total := 0


func check(what: String, ok: bool) -> void:
	total += 1
	passed += int(ok)
	print("%s %s" % ["PASS" if ok else "FAIL", what])


func _initialize() -> void:
	var loot: Node = load("res://loot.gd").new()
	var no_events: Array[Dictionary] = []
	check("no drops give {}", loot.total_drops(no_events) == {})
	var fight: Array[Dictionary] = [{"item": "gem", "count": 2}, {"item": "coin", "count": 5}, {"item": "gem", "count": 1}]
	var before := fight.duplicate(true)
	var totals = loot.total_drops(fight)
	var got: Dictionary = totals if totals is Dictionary else {}
	check("gems and coins add up", got == {"gem": 3, "coin": 5})
	check("items in the order they first dropped", got.keys() == ["gem", "coin"])
	check("the result is a Dictionary[String, int]",
		got.is_typed() and got.get_typed_key_builtin() == TYPE_STRING and got.get_typed_value_builtin() == TYPE_INT)
	check("the events are unchanged", fight == before)

	var base := {"hp": 100, "atk": 12, "res": {"fire": 10}}
	var base_before := base.duplicate(true)
	var mods: Array[Dictionary] = [{"atk": 5}, {"crit": 2}, {"atk": 3, "res": {"fire": 20, "ice": 5}}]
	var mods_before := mods.duplicate(true)
	var hero: Dictionary = loot.apply_modifiers(base, mods)
	check("atk and crit add up", hero.atk == 20 and hero.crit == 2 and hero.hp == 100)
	check("nested res adds up", hero.res == {"fire": 30, "ice": 5})
	check("base is unchanged, nested res too", base == base_before)
	check("the modifiers are unchanged", mods == mods_before)
	var solo := {"hp": 50}
	var none: Array[Dictionary] = []
	var copy: Dictionary = loot.apply_modifiers(solo, none)
	copy.hp = 1
	check("with no modifiers the result is a copy, not base", solo.hp == 50)
	loot.free()
	print("%d of %d checks passed" % [passed, total])
	quit(0 if passed == total else 1)

Here is the checker’s report on a loot.gd that does the job, and on a tempting one:

The checker on a right answer and on a tempting one, Godot 4.7.2 Python · check_session.py
from session import files, run

# The checker on a loot.gd that solves the task, then on a tempting one with two mistakes.
files("practice/project.godot=right/project.godot", "practice/check_loot.gd=right/check_loot.gd",
      "practice/solution/loot.gd=right/loot.gd",
      "practice/project.godot=tempting/project.godot", "practice/check_loot.gd=tempting/check_loot.gd",
      "practice/tempting/loot.gd=tempting/loot.gd")
run("""
cd right
godot --headless --no-header --script check_loot.gd
cd ../tempting
!godot --headless --no-header --script check_loot.gd
""")

Output

$ cd right
$ godot --headless --no-header --script check_loot.gd
PASS no drops give {}
PASS gems and coins add up
PASS items in the order they first dropped
PASS the result is a Dictionary[String, int]
PASS the events are unchanged
PASS atk and crit add up
PASS nested res adds up
PASS base is unchanged, nested res too
PASS the modifiers are unchanged
PASS with no modifiers the result is a copy, not base
10 of 10 checks passed
$ cd ../tempting
$ godot --headless --no-header --script check_loot.gd
PASS no drops give {}
SCRIPT ERROR: Invalid access to property or key 'gem' on a base object of type 'Dictionary[String, int]'.
          at: total_drops (res://loot.gd:11)
          GDScript backtrace (most recent call first):
              [0] total_drops (res://loot.gd:11)
              [1] _initialize (res://check_loot.gd:20)
FAIL gems and coins add up
FAIL items in the order they first dropped
PASS the result is a Dictionary[String, int]
PASS the events are unchanged
PASS atk and crit add up
PASS nested res adds up
FAIL base is unchanged, nested res too
PASS the modifiers are unchanged
PASS with no modifiers the result is a copy, not base
7 of 10 checks passed
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_session.py

The tempting total_drops() adds to totals[event.item] before that key exists, so the first gem stopped the function with Invalid access to property or key 'gem'. Its apply_modifiers() starts from base.duplicate(), a shallow copy, so adding fire resistance changed the base hero’s own res. Your output should end with 10 of 10 checks passed. Then take the questions on this page’s five ideas; the one you know least comes first, and each answer says what it gets right or misses:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 stats is {"hp": 100}. Which line reads the same value as stats["hp"]?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: stats.hp

    stats.hp and stats["hp"] both printed 100 in the run. Use the brackets when the key is in a variable.

  2. Question 2 of 15 stats is {"hp": 100}. What does 100 in stats return?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: false, because in looks only at keys

    For a dictionary, in and has() ask about keys only.

  3. Question 3 of 15 target is {"enemy": null}. What does target.get("enemy", "none") return?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: null

    When null is a value you store, test has(key) to tell "no entry" from "an entry that is null".

  4. Question 4 of 15 In which order does a for loop over a GDScript dictionary visit the keys?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: In the order the entries were added

    keys(), values() and a printout show the same order.

  5. Question 5 of 15 stats is {"hp": 100, "atk": 12, "def": 5}. After stats.erase("atk") and stats["atk"] = 15, what are its keys?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: ["hp", "def", "atk"]

    The run printed { "hp": 100, "def": 5, "atk": 15 } after the erase and the write.

  6. Question 6 of 15 What does {"x": 1, "y": 2} == {"y": 2, "x": 1} return?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: true

    == for contents, is_same() for "one dictionary under two names".

  7. Question 7 of 15 What is the difference between a.merge(b) and a.merged(b)?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: merge() changes a; merged() returns a new dictionary and leaves a alone

    Use merged() when the dictionary you start from must stay as it was, such as defaults.

  8. Question 8 of 15 Saved settings must win over the defaults, and the defaults must not change. Which call fits?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: DEFAULTS.merged(saved, true)

    Start from the dictionary whose keys you want, and pass true when the other one's values must win.

  9. Question 9 of 15 defaults is a var whose bindings entry is a nested dictionary. settings := defaults.merged(saved, true), and saved has no bindings. A player then rebinds jump in settings.bindings. What happens to defaults?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: defaults.bindings changes too, because settings.bindings is the same nested dictionary

    Start from defaults.duplicate(true), then write the saved values into that copy, merging nested ones key by key.

  10. Question 10 of 15 Which declaration is a typed dictionary of int values by String keys?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: var stock: Dictionary[String, int] = {}

    Typed dictionaries need Godot 4.4 or later.

  11. Question 11 of 15 loose is a plain Dictionary variable holding prices, a Dictionary[String, int]. What happens on loose["bomb"] = "cheap"?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: It is refused when the line runs, and the function stops

    loose.set("bomb", "cheap") is the gentle form: it returned false and the function went on.

  12. Question 12 of 15 A loader returns a plain Dictionary of prices. How do you get its entries into a Dictionary[String, int] variable?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: Make an empty typed dictionary and call assign() with the loaded one

    assign() converts what it can, such as 1.5 to 1, and stops at the first key or value it cannot.

  13. Question 13 of 15 After var leader := asha, what does leader.level = 5 do to asha?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: asha.level is 5, because both names hold one dictionary

    Use duplicate() or duplicate(true) when you need a separate dictionary.

  14. Question 14 of 15 When is duplicate() enough, so that duplicate(true) is not needed?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: When no value in the dictionary is an array or a dictionary that the copy will change

    Ravi's name was his own after duplicate(), but his stats were Asha's.

  15. Question 15 of 15 A test should prove that two save slots share no data. Which check proves it for their nested inventory?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: is_same(slot_a.inventory, slot_b.inventory) is false

    The run used is_same(asha.stats, ravi.stats), true, and is_same(asha.stats, mira.stats), false.

Interview questions

Warm-up: how do you read a value from a GDScript dictionary when the key might be missing?

Use get() with a default: stats.get("luck", 0) returns 0 when there is no key "luck" and adds nothing to the dictionary. Square brackets on a missing key are an error that stops the function, as stats["luck"] did on Godot 4.7.2, and get() without a default returns null. When you need to know whether the key is there, ask stats.has("luck") or "luck" in stats; both look only at keys. One case needs care: a key whose value is null. {"enemy": null}.get("enemy", "none") returned null, because the key exists, so test has() when null is a value your game stores. In a typed dictionary get() still returns a Variant, so write the type yourself: var cost: int = prices.get("bow", 0).

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A settings menu starts with var settings = defaults and loads the save with settings.merge(saved, true). After a player sets the volume to 35, Reset to defaults (settings = defaults.duplicate()) keeps 35 instead of the default 80. Explain the bug, fix it, and say how you would stop it coming back.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A game counts kills in a dictionary keyed by enemy id, an int, saves it with JSON.stringify() and loads it back with JSON.parse_string(). After loading, kills.get(enemy_id, 0) returns 0 for every enemy, though the file holds the counts. What do you check, and how do you fix the loader?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A dictionary finds a value by its key; get(key, default) and has() deal with a missing key, which square brackets turn into an error, and keys of different types, such as 1, 1.0 and “1”, are different keys.
  • Entries keep the order they were added in, which makes a dictionary a natural counter with get(key, 0) + 1; sort() orders the keys, and == ignores order.
  • merge() changes the dictionary it is called on and merged() returns a new one; overwrite decides whose value wins, and a nested dictionary is replaced whole and shared, never merged.
  • A typed dictionary such as Dictionary[String, int] (Godot 4.4 and later) is checked by the parser and at run time, converts what it can, and its methods still return a Variant.
  • Assignment, parameters and duplicate() share nested dictionaries; duplicate(true) copies the dictionaries and arrays inside as well.

Terms in this lesson

dictionary
A collection of entries, each a value stored under a unique key, such as {"hp": 100}. A lookup by key finds the value, and a GDScript Dictionary keeps its entries in the order they were added.
typed dictionary
A dictionary declared with a key type and a value type, such as Dictionary[String, int], in Godot 4.4 and later. It refuses keys and values it cannot convert to those types, and its methods still return Variant.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.