False Friends — Same Shape, Different Answer
Integer division gives a different answer
Start here. The two columns are byte-identical and they print different things. GDScript divides two integers as integers; Python 3 always produces a float from
/.print(7 / 2)
print(7 % 2)
print(-7 / 2)print(7 / 2)
print(7 % 2)
print(-7 / 2)GDScript prints
3, Python prints 3.5. Python's integer division is a separate operator, //, and it floors rather than truncating — so -7 // 2 is -4 where GDScript's -7 / 2 is -3. Nothing warns you about either, which is what makes this the most expensive difference on the page.A default argument is evaluated once
The same function, written the same way. GDScript builds a fresh array on every call; Python evaluates the default once, when the function is defined, and every call shares it.
func add_item(item: String, bag: Array = []) -> Array:
bag.append(item)
return bag
print(add_item("coin"))
print(add_item("gem"))def add_item(item, bag=[]):
bag.append(item)
return bag
print(add_item("coin"))
print(add_item("gem"))Python prints
['coin'] then ['coin', 'gem'] — the second call inherits the first call's data. This is the single most famous Python gotcha, and a GDScript habit walks straight into it. The fix is bag=None and building the list inside.Assigning a list shares it — in both, but watch the numbers
The list half behaves identically — both share, and the append is visible through both names. The second half is where the analogy runs out: GDScript has value types like
Vector2 that copy on assignment, and Python has no such thing.var first := [1, 2]
var second := first
second.append(3)
print(first.size())
var a := Vector2(1, 2)
var b := a
b.x = 99
print(a.x)first = [1, 2]
second = first
second.append(3)
print(len(first))
a = [1.0, 2.0]
b = a.copy()
b[0] = 99
print(a[0])In Python everything is a reference, so a copy has to be asked for —
.copy(), or copy.deepcopy when the contents are themselves containers. The GDScript instinct that "some things copy themselves" has no equivalent to rely on.Hello World & Output
Hello, World
Byte-identical, and this time they agree. Both columns run for real: the GDScript in an actual Godot engine, the Python in the browser.
print("Hello, World!")print("Hello, World!")This is the row that makes the rest of the page necessary. When so much is genuinely the same, the differences stop announcing themselves.
Putting a value in a message
GDScript's
% formatting is Python's older style, and it still works there — with a tuple rather than an array. The modern spelling is the f-string, which puts the expression where it appears.var player_name := "Robi"
var score := 1200
print("%s scored %d" % [player_name, score])player_name = "Robi"
score = 1200
print(f"{player_name} scored {score}")Python's
% form takes % and a tuple, ("Robi", 1200), not a list, so the GDScript line does not port across unchanged even though it looks like it should. The f-string avoids the question entirely.Variables & Types
Type annotations stop being enforced
Python has annotations that look like GDScript's and mean something different: nothing at runtime. They are documentation that external tools can check, and the interpreter ignores them.
var speed: float = 200.0
var label := "boss"
print(speed)
print(label)speed: float = 200.0
label = "boss"
print(speed)
speed = "not a number"
print(speed)Assigning a string to something annotated
float runs perfectly happily. In GDScript that same annotation is a runtime guarantee — it is the difference between a hint and a rule, wearing the same syntax.There are no constants
GDScript's
const is enforced. Python has no such keyword: the convention is a capitalized name, and nothing stops anyone reassigning it.const MAX_HEALTH := 100
print(MAX_HEALTH)
enum State { IDLE, WALKING }
print(State.WALKING)from enum import Enum
MAX_HEALTH = 100
print(MAX_HEALTH)
class State(Enum):
IDLE = 0
WALKING = 1
print(State.WALKING)Enums are a standard-library class rather than a language keyword, and a Python enum member prints as
State.WALKING rather than its number — it keeps its name, where a GDScript enum value simply is an integer.Strings
Length, case, and slicing
Length is a free function rather than a method, and slicing uses bracket ranges. The
in operator is spelled the same and means the same thing in both.var title := "Crystal Cavern"
print(title.length())
print(title.to_upper())
print(title.substr(0, 7))
print("Cave" in title)title = "Crystal Cavern"
print(len(title))
print(title.upper())
print(title[0:7])
print("Cave" in title)Python's slice syntax goes further than
substr: title[-6:] takes the last six characters and title[::-1] reverses the string. Negative indices work throughout, which GDScript supports for arrays but not for string slicing.Splitting and joining
These are the same, including the direction of
join — the separator is what you call the method on, which surprises people coming the other way.var csv := "sword,shield,potion"
var items := csv.split(",")
print(items)
print(" + ".join(items))csv = "sword,shield,potion"
items = csv.split(",")
print(items)
print(" + ".join(items))Printing the list also works in both, though the punctuation differs: Python shows
['sword', 'shield', 'potion'] with quotes, GDScript shows its own array formatting. A row that agrees on behavior can still differ in what it prints.Lists, Tuples & Sets
The everyday array
This is the closest correspondence on the page.
append, bracket indexing and negative indices are all identical; only size() becomes len().var loot := ["sword", "shield", "potion"]
loot.append("rope")
print(loot.size())
print(loot[0])
print(loot[-1])
print("shield" in loot)loot = ["sword", "shield", "potion"]
loot.append("rope")
print(len(loot))
print(loot[0])
print(loot[-1])
print("shield" in loot)A Python list also holds mixed types without complaint, exactly as an untyped GDScript
Array does. What Python does not have is GDScript's Array[int] — the typed variant that is checked at runtime.Two containers you did not have
A tuple is a list that cannot change, written with parentheses — useful for a fixed pair like a coordinate. A set holds each value once, which GDScript fakes with a dictionary of throwaway values.
var point := [3, 4]
print(point)
var seen := {}
for tag in ["fire", "ice", "fire"]:
seen[tag] = true
var unique := seen.keys()
unique.sort()
print(unique)point = (3, 4)
print(point)
seen = set()
for tag in ["fire", "ice", "fire"]:
seen.add(tag)
print(sorted(seen))The set is the bigger gain: it brings
|, & and - for union, intersection and difference, which is the reason to reach for one rather than the deduplication alone. Tuples also unpack — x, y = point — which is how Python returns several values.Sorting by something
GDScript wants a comparator taking two arguments. Python wants a
key — one argument, returning the value to order by — which is shorter and cannot be got backwards.var enemies := [
{"name": "bat", "hp": 12},
{"name": "golem", "hp": 90},
{"name": "slime", "hp": 30},
]
enemies.sort_custom(func(a, b): return a["hp"] < b["hp"])
for enemy in enemies:
print("%s %d" % [enemy["name"], enemy["hp"]])enemies = [
{"name": "bat", "hp": 12},
{"name": "golem", "hp": 90},
{"name": "slime", "hp": 30},
]
enemies.sort(key=lambda enemy: enemy["hp"])
for enemy in enemies:
print(f"{enemy['name']} {enemy['hp']}")The data structure is unchanged here, which is worth noticing: unlike the C#, C++ and Rust pages, there is no pressure to replace the dictionary with a typed record, because Python is as happy with string keys as GDScript is.
Dictionaries
The everyday dictionary
The literal syntax is identical. Only the questions change:
has() becomes the in operator, and size() becomes len().var stats := {"strength": 12, "agility": 8}
stats["luck"] = 3
print(stats["strength"])
print(stats.has("agility"))
print(stats.size())stats = {"strength": 12, "agility": 8}
stats["luck"] = 3
print(stats["strength"])
print("agility" in stats)
print(len(stats))A missing key raises
KeyError rather than returning null, so the failure surfaces where the typo is instead of several lines later when something unexpectedly holds nothing.Missing keys, and walking the pairs
get with a fallback works identically, which is a genuine relief. Iteration differs: looping a dict gives keys in both, but .items() hands you both halves and removes the second lookup.var config := {"volume": 0.8}
print(config.get("volume", 1.0))
print(config.get("brightness", 1.0))
var loadout := {"head": "helm", "hand": "mace"}
for slot in loadout:
print("%s -> %s" % [slot, loadout[slot]])config = {"volume": 0.8}
print(config.get("volume", 1.0))
print(config.get("brightness", 1.0))
loadout = {"head": "helm", "hand": "mace"}
for slot, item in loadout.items():
print(f"{slot} -> {item}")Python dictionaries have preserved insertion order since 3.7, so the loop is reproducible without sorting. GDScript dictionaries also preserve insertion order, which makes this one of the places the two genuinely agree.
Control Flow
if / elif / else
Identical, down to
elif — which GDScript took from Python and which almost no other language spells that way.var health := 45
if health > 70:
print("healthy")
elif health > 25:
print("hurt")
else:
print("critical")health = 45
if health > 70:
print("healthy")
elif health > 25:
print("hurt")
else:
print("critical")The one thing to know is that Python is strict about mixing tabs and spaces and will refuse to run a file that does. GDScript's editor uses tabs; most Python style uses four spaces.
match, in both — with different rules
Both have structural pattern matching that binds parts of the value. Python needs the
case keyword on each arm; GDScript needs var on each binding.var command := ["move", 3]
match command:
["move", var distance]:
print("move %d" % distance)
["stop"]:
print("stop")
_:
print("unknown")command = ["move", 3]
match command:
case ["move", distance]:
print(f"move {distance}")
case ["stop"]:
print("stop")
case _:
print("unknown")Neither checks that you covered every possibility, so the catch-all matters in both. Python's version arrived in 3.10 and is newer than GDScript's, which is an unusual direction for this page.
Loops & Comprehensions
Walking and counting
Byte-identical apart from the indentation character,
range() included — start, stop and step mean the same things.var party := ["Ari", "Bex", "Cyd"]
for member in party:
print(member)
for i in range(3):
print(i)
for i in range(2, 8, 2):
print(i)party = ["Ari", "Bex", "Cyd"]
for member in party:
print(member)
for i in range(3):
print(i)
for i in range(2, 8, 2):
print(i)GDScript took
range() from Python directly. One difference hides here: Python's range is a lazy object that generates values as needed, while GDScript's builds a real array — which matters only when the count is very large.Comprehensions replace the accumulate loop
This is the idiom that most changes how your code looks. The build-an-empty-list-and-append loop is one line in Python, and reads in the order you would say it: what to keep, from where, filtered how.
var scores := [42, 91, 7, 68, 15]
var high := []
for score in scores:
if score > 40:
high.append(score * 10)
print(high)scores = [42, 91, 7, 68, 15]
high = [score * 10 for score in scores if score > 40]
print(high)The same shape builds dictionaries (
{k: v for ...}) and sets ({x for ...}). GDScript has map and filter that take lambdas, but nothing this compact — and comprehensions are what Python code you read will be full of.Needing the index too
The GDScript habit is to loop over indices and index back into the array.
enumerate pairs each element with its position, and takes a starting number so the + 1 disappears too.var waves := ["bats", "slimes", "boss"]
for i in range(waves.size()):
print("wave %d: %s" % [i + 1, waves[i]])waves = ["bats", "slimes", "boss"]
for index, wave in enumerate(waves, start=1):
print(f"wave {index}: {wave}")Along with
zip, which walks two collections together, this removes most reasons to write an index at all — and with them the class of bug where the index and the collection drift apart.Functions
Declaring a function
func becomes def and everything else is the same, arrow included. max is a global in both.func damage_after_armor(damage: int, armor: int) -> int:
return max(damage - armor, 0)
print(damage_after_armor(30, 12))
print(damage_after_armor(5, 12))def damage_after_armor(damage: int, armor: int) -> int:
return max(damage - armor, 0)
print(damage_after_armor(30, 12))
print(damage_after_armor(5, 12))The annotations are checked at runtime in GDScript and ignored in Python, as the Variables section showed — the same signature carrying a guarantee on one side and a comment on the other.
Naming arguments at the call site
Defaults behave the same — with the
[] caveat from the False Friends section. What Python adds is naming an argument at the call, which lets you skip a default or reorder for readability.func spawn(kind: String, count: int = 1, elite: bool = false) -> String:
return "%d %s%s" % [count, kind, " (elite)" if elite else ""]
print(spawn("bat"))
print(spawn("golem", 3))
print(spawn("slime", 2, true))def spawn(kind, count=1, elite=False):
return f"{count} {kind}{' (elite)' if elite else ''}"
print(spawn("bat"))
print(spawn("golem", 3))
print(spawn("slime", elite=True, count=2))The conditional expression is spelled identically in both,
a if condition else b, because GDScript took that from Python too. Note True and False are capitalized, which is a small and constant source of typos.Returning more than one value
The GDScript habit is a dictionary read back by string key. Python returns a tuple and unpacks it into names at the call site.
func split_damage(total: int) -> Dictionary:
return {"physical": total / 2, "fire": total - total / 2}
var parts := split_damage(9)
print(parts["physical"])
print(parts["fire"])def split_damage(total):
return total // 2, total - total // 2
physical, fire = split_damage(9)
print(physical)
print(fire)Note the
//: this is the integer-division false friend showing up in ordinary code, and using / here would have given 4.5 and a floating-point total. Unpacking also fails loudly if the count is wrong, where a mistyped dictionary key fails quietly.Classes
Declaring a class
Very close. The constructor is
__init__ rather than _init, there is no new — you call the class itself — and fields are created by assigning to self rather than declared.class Potion:
var strength: int
func _init(value: int) -> void:
strength = value
func describe() -> String:
return "potion of %d" % strength
var small := Potion.new(5)
print(small.describe())class Potion:
def __init__(self, strength):
self.strength = strength
def describe(self):
return f"potion of {self.strength}"
small = Potion(5)
print(small.describe())self is an explicit first parameter of every method. Forgetting it is the most common early Python error, and GDScript gives you self implicitly, so there is nothing in your habits to remind you.Inheritance
extends becomes parentheses after the class name. Overriding is silent in both — no keyword marks it, and a misspelled method name quietly adds a new one instead of replacing.class Enemy:
func speak() -> String:
return "..."
class Bat extends Enemy:
func speak() -> String:
return "screech"
var creature: Enemy = Bat.new()
print(creature.speak())class Enemy:
def speak(self):
return "..."
class Bat(Enemy):
def speak(self):
return "screech"
creature = Bat()
print(creature.speak())Python allows several parents where GDScript allows one, and calling the parent version is
super().speak() in both. This is the section where the two languages are most alike.Errors You Can Finally Raise
GDScript cannot raise; Python can
GDScript has no exceptions at all —
push_error writes to the debugger and execution continues, so a failed operation must return something and hope the caller checks.func withdraw(balance: int, amount: int) -> int:
if amount > balance:
push_error("insufficient funds")
return balance
return balance - amount
print(withdraw(100, 30))
print(withdraw(100, 500))def withdraw(balance, amount):
if amount > balance:
raise ValueError("insufficient funds")
return balance - amount
print(withdraw(100, 30))
try:
print(withdraw(100, 500))
except ValueError as error:
print(f"caught: {error}")Look at what the GDScript column is forced to do: return the unchanged balance, which cannot be told apart from a withdrawal of zero. Python stops the function, and an uncaught exception is loud rather than silent.
Cleanup that always happens
Without exceptions, every early exit in GDScript repeats its own cleanup — note
close appears twice on the left, and a third exit would need a third copy.var log := []
func risky(should_fail: bool, log_target: Array) -> void:
log_target.append("open")
if should_fail:
log_target.append("close")
return
log_target.append("work")
log_target.append("close")
risky(false, log)
risky(true, log)
print(log)log = []
def risky(should_fail):
log.append("open")
try:
if should_fail:
raise RuntimeError("failed")
log.append("work")
finally:
log.append("close")
risky(False)
try:
risky(True)
except RuntimeError:
pass
print(log)finally runs whether the block finished, returned, or raised, so the cleanup is written once. The with statement builds on it, which is how Python closes files without anyone remembering to.The Library You Gain
JSON, and a real standard library
Godot has JSON built in, so this one is a fair fight. It is here to introduce
import, which is the door to the rest.var save := {"level": 3, "items": ["sword", "rope"]}
var text := JSON.stringify(save)
print(text)
var parsed = JSON.parse_string(text)
print(parsed["level"])import json
save = {"level": 3, "items": ["sword", "rope"]}
text = json.dumps(save)
print(text)
parsed = json.loads(text)
print(parsed["level"])Behind that door is the actual reason to learn Python:
re for regular expressions, datetime, pathlib, csv, sqlite3 and itertools all ship with the interpreter, and pip reaches everything else. This is why the tooling around a game tends to end up written here.Regular expressions without ceremony
Both languages have regular expressions. GDScript needs an object compiled before use; Python's module-level functions compile and cache for you.
var line := "wave 12: bats"
var regex := RegEx.new()
regex.compile("wave (\\d+)")
var found := regex.search(line)
if found:
print(found.get_string(1))import re
line = "wave 12: bats"
found = re.search(r"wave (\d+)", line)
if found:
print(found.group(1))The
r prefix makes a raw string, so backslashes mean what they say — which is why Python regex patterns are almost always written that way. Godot's RegEx is a real engine class, so this row is a difference of ceremony rather than capability.What Has No Equivalent
Nothing calls your code for you
This is the largest difference on the page and the one with no workaround. A Godot script is called by the engine:
_ready when it enters the tree, _process once a frame, forever. Python has no such thing — if nothing calls your function, it never runs.extends Node2D
func _ready() -> void:
print("ready")
func _process(delta: float) -> void:
position.x += 200.0 * deltaimport time
class Player:
def __init__(self):
self.x = 0.0
print("ready")
def process(self, delta):
self.x += 200.0 * delta
player = Player()
previous = time.monotonic()
for _ in range(3):
now = time.monotonic()
player.process(now - previous)
previous = now
print(round(player.x, 1) >= 0.0)Whatever you build has to supply its own loop, and the frameworks people reach for (pygame, a web server, an event loop) are all answers to that one question. The GDScript column is not "better" — it is a program running inside something that already has a loop.
No signals — but the pattern is writable
Signals are a Godot feature, not a language one, so Python has nothing built in. What it does have is functions as values, which is all a signal really needs.
class Doorbell:
signal rung(label: String)
func press(label: String) -> void:
rung.emit(label)
var bell := Doorbell.new()
bell.rung.connect(func(label): print("rung: %s" % label))
bell.press("front door")class Doorbell:
def __init__(self):
self.listeners = []
def connect(self, handler):
self.listeners.append(handler)
def press(self, label):
for handler in self.listeners:
handler(label)
bell = Doorbell()
bell.connect(lambda label: print(f"rung: {label}"))
bell.press("front door")Eleven lines replace one keyword, and they are the eleven lines Godot is running on your behalf. Nothing here is hard — the point is that the editor cannot draw a connection between two nodes for you, because there are no nodes.
Drawing It, Side by Side
A histogram, drawn and printed
The GDScript column below runs in a real Godot engine, and the chart under it is that engine drawing. The Python column runs too — and prints, because there is no canvas to draw on. That contrast is the row.
extends Control
func _ready() -> void:
queue_redraw()
func _draw() -> void:
var pane := get_viewport_rect().size
var counts := [4, 9, 6, 12, 3, 8]
var biggest := 12.0
var slot := (pane.x - 60.0) / counts.size()
var font := ThemeDB.fallback_font
for i in counts.size():
var count: int = counts[i]
var height := (pane.y - 70.0) * (count / biggest)
var left := 30.0 + i * slot
draw_rect(Rect2(left, pane.y - 34.0 - height, slot - 10.0, height),
Color.from_hsv(0.55 - i * 0.06, 0.55, 0.95), true)
draw_string(font, Vector2(left + 4, pane.y - 12.0), str(count),
HORIZONTAL_ALIGNMENT_LEFT, -1, 15)counts = [4, 9, 6, 12, 3, 8]
biggest = max(counts)
for index, count in enumerate(counts):
bar = "#" * round(count / biggest * 30)
print(f"{index}: {bar} {count}")Both compute the same proportions from the same data; only the output device differs. Drawing in Python means reaching for a library — matplotlib, Pillow, pygame — and every one of them is a separate install and a separate loop, which is the practical shape of "nothing calls your code for you".