Running It & Output
Hello, World
Byte for byte identical, which is a fair signal of how much of this page will feel familiar. Both languages are interpreted, both run a file top to bottom with no entry point to declare, and both spell the output function the same way.
print("Hello, World!")print("Hello, World!")The separator is the first tiny difference: Lua's
print puts a tab between arguments and Python's puts a space. Python also lets you change it — print(a, b, sep="") — which Lua has no equivalent for.Indentation replaces do/end
The first real adjustment. Lua closes every block with
end and treats indentation as decoration; Python takes its structure from the indentation itself, with a colon opening the block.local ready = true
if ready then
print("blocks are delimited by keywords")
print("indentation is decorative")
endready = True
if ready:
print("blocks are delimited by INDENTATION")
print("getting this wrong changes the program")The practical effect is that a misplaced line does not fail to parse — it silently belongs to a different block. There is no
end to line up against, so the indentation is the only record of what you meant. Four spaces is the universal convention, and mixing tabs with spaces is an error.Comments
-- A single-line comment.
--[[ A long comment
spanning several lines. ]]
print("commented")# A single-line comment.
"""A triple-quoted string, used as a block comment.
It is really a string expression that is evaluated
and discarded."""
print("commented")Python has no true block comment. A triple-quoted string is the convention, and in the first position of a module, class or function it becomes the docstring — readable at run time through
__doc__ and by help(), which Lua has no counterpart to.True and False are capitalized
A small thing that trips everyone on day one: Python capitalizes its three singleton values where Lua writes them in lower case.
local ready = true
local done = false
print(ready, done, nil)ready = True
done = False
print(ready, done, None)Writing
true in Python is not a syntax error — it is a name lookup, so it fails at run time with NameError: name 'true' is not defined. The error names the mistake clearly, which is more than most such slips manage.Variables & Scope
There is no local keyword
Lua makes you write
local and punishes you with a global if you forget. Python inverts the default: an assignment inside a function creates a local, and reaching outward requires a keyword.local counter = 0 -- 'local' or it is a global
counter = counter + 1
print(counter)counter = 0 # local to this scope by default
counter = counter + 1
print(counter)That is the safer default, and it removes Lua's most notorious footgun outright. The keywords that opt out are
global and nonlocal, and they are rare enough in real code that most Python programmers seldom write them.🚨 Assigning to an outer variable needs nonlocal
The mirror image of the previous row, and the place it actually costs you. Lua assigns straight through to the upvalue. Python's default means the assignment creates a fresh local, and the outer one is never touched.
local function make_counter()
local count = 0
return function()
count = count + 1 -- just works: assigns the upvalue
return count
end
end
local next_value = make_counter()
print(next_value(), next_value())def make_counter():
count = 0
def increment():
nonlocal count # without this, 'count' would be a NEW local
count += 1
return count
return increment
next_value = make_counter()
print(next_value(), next_value())Without
nonlocal this raises UnboundLocalError rather than silently doing nothing, because count += 1 reads before it writes. That is a mercy — the closest GDScript equivalent fails silently — but it is still the single most common surprise for someone carrying Lua closure habits over.Blocks do not create scope
Lua scopes a local to its enclosing block, so a name declared inside
do … end or an if body disappears at the end of it. Python has no block scope at all.local outer = "visible"
do
local inner = "block only"
print(outer, inner)
end
print(inner) -- nil: the block ended its scopeouter = "visible"
if True:
inner = "not block only"
print(outer, inner)
print(inner) # still visible: only FUNCTIONS create scopeA name assigned anywhere in a function is visible everywhere in that function, including before the line that assigned it (where it raises rather than reading as absent). A loop variable also outlives its loop, which is occasionally useful and more often a surprise.
Multiple assignment works, and unpacks further
A genuine convergence: both languages evaluate the whole right-hand side before assigning, so the one-line swap works identically in both. Python then goes further.
local first, second = "a", "b"
print(first, second)
first, second = second, first
print(first, second)first, second = "a", "b"
print(first, second)
first, second = second, first
print(first, second)
head, *rest = [1, 2, 3, 4] # no Lua equivalent
print(head, rest)Python's version is tuple unpacking rather than a special assignment form, so it works on any iterable, nests (
(a, b), c = (1, 2), 3), and supports a starred name that collects the rest. Lua's multiple assignment does none of those.Neither language has real constants
Neither language can stop you reassigning a name. Both settle for a capitalized identifier and a team convention, which is worth noting precisely because so many other languages differ here.
-- The convention is a capitalized name and an agreement.
local MAX_PLAYERS = 4
print(MAX_PLAYERS)# The same convention, and the same lack of enforcement.
MAX_PLAYERS = 4
print(MAX_PLAYERS)
from typing import Final
LIMIT: Final = 10 # checked by a type checker, not by Python
print(LIMIT)Final is a hint for a static type checker such as mypy; the interpreter itself ignores it entirely and the assignment still succeeds. Lua 5.4 added a genuine <const> attribute, but Fengari implements 5.3, so this page cannot show it.None, Truthiness, and the Zero Trap
🚨 Zero and empty are falsy
The most important row on the page. Lua has exactly two falsy values; Python treats emptiness as false — zero, the empty string, the empty list, the empty dict and
None all fail a truth test.-- In Lua ONLY nil and false are falsy.
for _, value in ipairs({ 0, "", "0" }) do
if value then print(tostring(value) .. " is truthy") end
end# In Python 0, "", [], {} and None are all FALSY.
for value in [0, "", "0", [], {}]:
if value:
print(repr(value), "is truthy")
else:
print(repr(value), "is FALSY")So
if count then in Lua passes for a count of zero and if count: in Python does not. Every guard written around a number or a collection changes meaning in the port, silently. Where you mean "was it supplied", test is not None explicitly.nil becomes None
The absent value is
None, and it behaves much like nil — falsy, returned by a function with no return, and usable as a default.local target = nil
if target == nil then
print("no target")
end
print(type(target))target = None
if target is None: # 'is', not '=='
print("no target")
print(type(target).__name__)The idiomatic comparison is
is None rather than == None, because is tests identity and cannot be intercepted by a class's __eq__. Unlike nil, None is a real object with a type, so it can be stored in a list or a dict value without disappearing.🚨 A missing dict key raises
Reading an absent key is completely safe in Lua and yields
nil, which is why Lua code reads optional configuration so casually. Python raises a KeyError.local settings = { width = 80 }
print(settings.height) -- nil, and nothing goes wrong
print(settings["missing"]) -- also nilsettings = {"width": 80}
# print(settings["height"]) # <- KeyError: 'height'
print(settings.get("height")) # None
print(settings.get("height", 24)) # 24, with a default
print("height" in settings) # FalseThe three safe forms are
get(key), get(key, default) and an in test. This is the most common crash when carrying Lua habits over, and the same trap appears on /lua/gdscript for the same reason.Assigning None does not delete
In Lua, assigning
nil to a table key is the only way to delete it. In Python that stores a None value and leaves the key in place.local inventory = { sword = 1, shield = 2 }
inventory.shield = nil -- assigning nil REMOVES the key
local count = 0
for _ in pairs(inventory) do count = count + 1 end
print(count)inventory = {"sword": 1, "shield": 2}
inventory["shield"] = None # the key REMAINS, holding None
print(len(inventory))
del inventory["shield"] # this removes it
print(len(inventory))del is the removal statement, and pop(key, default) removes and returns in one step without raising when the key is absent. Carrying the Lua habit over produces a dict whose len never goes down.The or-default idiom, and where it breaks
The
or fallback is Lua's standard way to default a parameter, and it works because only nil and false can trigger it. Ported literally, it also swallows 0, "" and an empty list.local function greet(name, greeting)
greeting = greeting or "Hello"
return greeting .. ", " .. name
end
print(greet("Ada"))
print(greet("Ada", "Welcome"))def greet(name, greeting="Hello"): # a real default parameter
return greeting + ", " + name
print(greet("Ada"))
print(greet("Ada", "Welcome"))
configured = 0
print(configured or 10) # 10 -- the idiom eats a valid zero
print(configured if configured is not None else 10) # 0Python has a real default parameter, applied when the argument is genuinely absent, which is the right tool. When you do need a fallback expression, test
is not None rather than relying on truthiness.Numbers: Where the Two Agree
Division agrees exactly
Worth showing side by side because this is where nearly every other language diverges. Lua 5.3 adopted
// from Python and made / always produce a float, so the two agree operator for operator.print(7 / 2) -- 3.5: / always produces a float
print(7 // 2) -- 3: floor division
print(7 % 3) -- 1print(7 / 2) # 3.5: / always produces a float
print(7 // 2) # 3: floor division
print(7 % 3) # 1Both also floor toward negative infinity rather than truncating toward zero, so
-7 // 2 is -4 in both. C, GDScript and JavaScript each get this differently, and the C and GDScript pages have a gotcha row about it — this page does not need one.Python integers do not overflow
Lua integers are 64-bit and wrap silently on overflow. Python integers are arbitrary precision and simply grow.
print(math.maxinteger)
print(math.maxinteger + 1 == math.mininteger) -- wraps at 64 bitsprint(2 ** 64) # exact
print(2 ** 200) # still exact: integers grow as needed
print(len(str(2 ** 1000))) # 302 digits, no overflow anywhereThere is no
math.maxinteger because there is no maximum. The cost is that large-integer arithmetic gets slower rather than wrong, which is almost always the better trade — and it removes a whole class of bug that the C page has to warn about. 🚨 Fengari, which runs the Lua column in your browser, uses 32-bit integers, so clicking run here prints 2147483647 rather than the 64-bit maximum a desktop lua reports. The language specifies 64-bit integers; this build does not provide them.int and float are separate types
Lua 5.3 split numbers into integer and float subtypes and Python has had separate types all along, so the distinction is already familiar.
print(math.type(7))
print(math.type(7.0))
print(7 == 7.0) -- trueprint(type(7).__name__)
print(type(7.0).__name__)
print(7 == 7.0) # True: compared by value
print(isinstance(True, int)) # True -- bool is a subclass of int!The Python-specific oddity is that
bool inherits from int, so True == 1 and True + True == 2. Lua keeps booleans strictly separate, and mixing them into arithmetic is an error there.No automatic string-to-number coercion
Lua coerces a numeric string in arithmetic, which Lua code often relies on when reading configuration. Python refuses and raises a
TypeError.print("10" + 5) -- 15: Lua coerces the string
print(tonumber("10") + 5) -- 15: the explicit version# print("10" + 5) # <- TypeError: can only concatenate str to str
print(int("10") + 5) # the explicit conversion
print("10" * 3) # "101010" -- * on a string REPEATS itThat refusal is deliberate and generally welcome — it catches the mistake at the point it happens. The one operator that does something surprising is
*, which repeats a string rather than raising, so "10" * 3 is not thirty. One browser caveat: Lua 5.3 makes string-to-number coercion produce a float, so Fengari prints 15.0 where a 5.4-or-later desktop lua prints 15. The coercion happens either way; only the subtype differs.Strings
Concatenation and f-strings
Lua concatenates with
..; Python overloads + and adds f-strings, which have no Lua equivalent.local name = "Ada"
print("Hello, " .. name .. "!")
print(string.format("%s is %d", name, 36))name = "Ada"
print("Hello, " + name + "!")
print(f"{name} is {36}")
print("%s is %d" % (name, 36)) # the older, Lua-like formAn f-string interpolates any expression and can carry a format spec —
f"{value:.2f}" — so it covers what string.format does while staying readable. The % operator is the older form and maps almost exactly onto Lua's string.format.Slicing replaces string.sub
Both store the length. The indexing differs the usual way — 0-based, with a half-open end — and Python's slice syntax generalizes far beyond what
string.sub does.local text = "hello"
print(#text)
print(text:sub(1, 1)) -- "h": 1-based, inclusive
print(text:sub(2, 3)) -- "el"
print(text:sub(-2)) -- "lo"text = "hello"
print(len(text))
print(text[0]) # "h": 0-based
print(text[1:3]) # "el": start inclusive, end EXCLUSIVE
print(text[-2:]) # "lo"Lua's
sub(2, 3) becomes [1:3]. Both languages accept negative indices counting from the end, which is one convenience they genuinely share. Python slices also take a step — text[::-1] reverses a string — which Lua has nothing like.Strings are immutable in both
A convergence worth stating: every string operation in both languages returns a new string, and neither can modify one in place.
local greeting = "hello"
local shouted = greeting:upper()
print(greeting, shouted)greeting = "hello"
shouted = greeting.upper()
print(greeting, shouted)The method-call syntax matches too. Lua reaches string methods through the string metatable's
__index; Python through the str type. The practical consequence is the same in both — building a string in a loop is quadratic, so collect the pieces and join them once.split and join are built in
Lua's standard library has no
split, so every Lua project grows the gmatch loop on the left. Python ships both halves.-- Lua has no split; you write it with gmatch.
local parts = {}
for piece in ("a,b,c"):gmatch("[^,]+") do
parts[#parts + 1] = piece
end
print(#parts, table.concat(parts, "|"))parts = "a,b,c".split(",")
print(len(parts), "|".join(parts))Note which side the separator is on:
"|".join(parts) puts it on the string and the sequence in the argument, the opposite of table.concat(parts, "|"). It is the single most commonly written-backwards line for a Lua programmer.Lua patterns become the re module
Lua patterns are a deliberately small non-backtracking subset built into the string type, invented to avoid shipping a regex engine. Python has full regular expressions, in a module you import.
local sentence = "one two three"
for word in sentence:gmatch("%a+") do
io.write(word, ";")
end
print()
print((sentence:gsub("%s+", "-")))import re
sentence = "one two three"
print(";".join(re.findall(r"[a-z]+", sentence)) + ";")
print(re.sub(r"\s+", "-", sentence))The character classes translate —
%a to [a-z], %s to \s, %d to \d — with the percent sign becoming a backslash. Alternation, grouping and lookaround have no Lua equivalent at all. Note also that many jobs need no regex here: split, startswith and in cover most of it.Bytes and text are different types
A Lua string is a counted sequence of bytes that knows nothing about encoding, so
# on non-ASCII text gives a byte count and utf8.len gives the character count.local text = "héllo"
print(#text) -- 6: BYTES, not characters
print(utf8.len(text)) -- 5: characterstext = "héllo"
print(len(text)) # 5: str is a sequence of CODE POINTS
print(len(text.encode("utf-8"))) # 6: bytes, once encoded
print(type(text.encode()).__name__)Python 3 separates the two into
str (code points) and bytes, and refuses to mix them without an explicit encode or decode. That separation is why len is always the answer you meant, and why file and network code has to say which it is dealing with.One Table Type Becomes Four
🚨 One table type becomes four
The single Lua table doing array, record, tuple and set duty is the deepest structural difference here. Python has four separate built-in types, each with its own literal syntax and its own methods.
-- ONE type does every job, and can do several at once.
local mixed = { 10, 20, 30, name = "Ada" }
print(#mixed, mixed[1], mixed.name)values = [10, 20, 30] # list: ordered, mutable
record = {"name": "Ada"} # dict: keyed
point = (3, 4) # tuple: ordered, IMMUTABLE
unique = {1, 2, 2, 3} # set: unordered, no duplicates
print(len(values), values[0], record["name"], point[0], len(unique))The two with no Lua counterpart are worth learning early. A tuple is immutable, which is what lets it be a dict key or a set member. A set gives O(1) membership testing and real union, intersection and difference operators — all of which Lua makes you build out of a table with
true values.Lists start at 0
Lua is one of the few languages indexing from 1. Python indexes from 0, and
enumerate is the idiomatic way to get the index alongside the value — the direct counterpart of ipairs.local values = { "first", "second", "third" }
print(values[1])
for index = 1, #values do
io.write(index, "=", values[index], " ")
end
print()values = ["first", "second", "third"]
print(values[0])
for index, value in enumerate(values):
print(index, "=", value)Unlike Lua, reading past the end raises an
IndexError rather than giving nil, so an off-by-one fails loudly. enumerate(values, start=1) will even count from 1 if you want to keep the Lua numbering while reading the list.ipairs and pairs become plain for loops
Lua's two iterators map onto two Python idioms:
enumerate for a sequence with its index, and .items() for a mapping's pairs.local values = { "a", "b" }
for index, value in ipairs(values) do
print(index, value)
end
local record = { x = 1, y = 2 }
for key, value in pairs(record) do
print(key, value)
endvalues = ["a", "b"]
for index, value in enumerate(values):
print(index, value)
record = {"x": 1, "y": 2}
for key, value in record.items():
print(key, value)Iterating a dict directly yields its keys, matching Lua's
pairs when you ignore the value. One real difference: Python dicts preserve insertion order and have since 3.7, where Lua's pairs order is explicitly unspecified and can vary between runs.Lists have methods
Lua puts these in the
table library as free functions taking the table first. Python makes them methods on the list.local values = { "b", "a" }
table.insert(values, "c")
table.sort(values)
print(table.concat(values, ","))
table.remove(values, 1)
print(table.concat(values, ","))values = ["b", "a"]
values.append("c")
values.sort()
print(",".join(values))
values.pop(0)
print(",".join(values))sort mutates in place and returns None, which catches people who write values = values.sort(); sorted(values) is the version that returns a new list. Lua's table.sort also mutates in place, so that half is familiar.Comprehensions, which Lua has nothing like
The filter-and-transform loop on the left is written in every Lua codebase, because Lua ships no
map, no filter and no comprehension. Python turns it into one expression.local numbers = { 1, 2, 3, 4 }
local doubled_evens = {}
for _, value in ipairs(numbers) do
if value % 2 == 0 then
doubled_evens[#doubled_evens + 1] = value * 2
end
end
print(table.concat(doubled_evens, ","))numbers = [1, 2, 3, 4]
doubled_evens = [value * 2 for value in numbers if value % 2 == 0]
print(",".join(str(value) for value in doubled_evens))
squares = {value: value ** 2 for value in range(3)} # dict comprehension
print(squares)Comprehensions exist for lists, dicts, sets and generators, and they are the most characteristically Python thing on this page. They are also the feature most worth learning early: a great deal of Python reads as one, and the equivalent Lua loop is four lines every time.
Dict keys must be hashable
Both languages keep numeric and string keys distinct, which is a real convergence — JavaScript, by contrast, collapses them. The difference is what else may be a key.
local lookup = {}
lookup[1] = "number key"
lookup["1"] = "string key" -- a DIFFERENT key
local key_table = {}
lookup[key_table] = "table key" -- any table works as a key
print(lookup[1], lookup["1"], lookup[key_table])lookup = {}
lookup[1] = "number key"
lookup["1"] = "string key" # a DIFFERENT key, as in Lua
lookup[(3, 4)] = "tuple key" # tuples are hashable
# lookup[[3, 4]] = "..." # <- TypeError: list is unhashable
print(lookup[1], lookup["1"], lookup[(3, 4)])A Lua table can key on any value including another table, compared by identity. Python requires the key to be hashable, so lists and dicts are rejected and tuples are accepted. That is why a coordinate pair is written
(3, 4) rather than [3, 4] when it is used as a key.Collections are references in both
A convergence that bites often: assignment copies a reference in both languages, so two names refer to one object.
local original = { 1, 2, 3 }
local alias = original
alias[1] = 99
print(original[1]) -- 99: the same table
local copy = { table.unpack(original) }
copy[1] = 1
print(original[1], copy[1])original = [1, 2, 3]
alias = original
alias[0] = 99
print(original[0]) # 99: the same list
copy = original.copy() # or original[:]
copy[0] = 1
print(original[0], copy[0])Both shallow copies leave nested collections shared. Python has
copy.deepcopy in the standard library for the real thing; Lua has no built-in deep copy at all and every project writes one.Control Flow
elseif becomes elif
Structurally identical; the keyword contracts to
elif, no parentheses are needed, and the block is opened by a colon and closed by dedenting.local score = 72
if score >= 90 then
print("A")
elseif score >= 70 then
print("B")
else
print("C")
endscore = 72
if score >= 90:
print("A")
elif score >= 70:
print("B")
else:
print("C")Python also chains comparisons the way mathematics does —
if 70 <= score < 90: is one expression, not two joined by and. Lua has no equivalent and needs the explicit conjunction.The numeric for becomes range
Lua's numeric
for takes an inclusive limit; range takes an exclusive one, so every bound shifts by one when translating.for index = 1, 5 do
io.write(index, " ")
end
print()
for index = 10, 1, -3 do
io.write(index, " ")
end
print()print(" ".join(str(index) for index in range(1, 6))) # end EXCLUSIVE
print(" ".join(str(index) for index in range(10, 0, -3)))A single argument,
range(5), counts 0 through 4, which pairs with zero-based indexing and is the form written most. range is also a lazy object rather than a list, so range(10 ** 9) costs nothing until iterated.while, break, and a real continue
The
while loops match. The difference is continue: Lua has none and fakes it with a goto to a label at the end of the body.local countdown = 3
while countdown > 0 do
io.write(countdown, " ")
countdown = countdown - 1
end
print()
for index = 1, 5 do
if index % 2 == 0 then goto continue end
io.write(index, " ")
::continue::
end
print()countdown = 3
while countdown > 0:
print(countdown, end=" ")
countdown -= 1
print()
for index in range(1, 6):
if index % 2 == 0:
continue # a real keyword
print(index, end=" ")
print()Python has the keyword, and compound assignment (
countdown -= 1) which Lua lacks entirely. Note print(value, end=" ") as the counterpart of io.write — it suppresses the newline rather than writing raw.There is no repeat/until
Lua's bottom-tested loop has no Python counterpart; the language has only
while and for.local attempts = 0
repeat
attempts = attempts + 1
until attempts >= 3
print(attempts)attempts = 0
while True:
attempts += 1
if attempts >= 3:
break
print(attempts)The translation moves the test to the bottom as a
break, and keeps Lua's sense — it stops when the condition becomes true — rather than inverting it. Python also allows an else on a loop, which runs when the loop finished without breaking; Lua has nothing like it.match, and Lua's table dispatch
Lua has no
switch, and the community answer is a table mapping names to functions — which is genuinely elegant and is worth keeping in Python too for dispatch tables. Python 3.10 added structural pattern matching.-- Lua has no switch; the idiom is a table of functions.
local handlers = {
go = function() return "moving" end,
stop = function() return "halted" end,
}
local command = "stop"
local handler = handlers[command]
print(handler and handler() or "unknown")command = "stop"
match command:
case "go":
print("moving")
case "stop":
print("halted")
case _:
print("unknown")match is more than a switch: it destructures sequences and mappings, binds names, and matches on class shape with case Point(x=0, y=y). There is no fall-through, so no break is needed. Lua has no equivalent at any level.Functions
def instead of function
Both treat functions as ordinary values that can be stored, passed and returned. The keyword changes and the block becomes indented.
local function add(left, right)
return left + right
end
local apply = function(operation, a, b)
return operation(a, b)
end
print(apply(add, 2, 3))def add(left, right):
return left + right
def apply(operation, a, b):
return operation(a, b)
print(apply(add, 2, 3))Python's anonymous form,
lambda, is limited to a single expression — no statements, no multi-line body — so it is far less capable than Lua's anonymous function. Where Lua would use an inline function, Python usually names one with def.Multiple returns become a tuple
This is the closest any target language on the Lua anchor comes to Lua's multiple return values. Python returns a single tuple, and unpacking it at the call site makes it read identically.
local function bounds(numbers)
local smallest, largest = numbers[1], numbers[1]
for _, value in ipairs(numbers) do
if value < smallest then smallest = value end
if value > largest then largest = value end
end
return smallest, largest
end
local low, high = bounds({ 4, 1, 9 })
print(low, high)def bounds(numbers):
return min(numbers), max(numbers) # returns a TUPLE
low, high = bounds([4, 1, 9])
print(low, high)The difference shows when you do not unpack: Lua's extra values are discarded, while Python hands you the tuple itself. That is usually an advantage — the result can be stored, passed on, or used as a dict key.
Varargs, plus keyword arguments
Lua's
... must be packed into a table before iterating and counted with select("#", ...). Python's *values arrives as a real tuple, already countable.local function sum(...)
local total = 0
for _, value in ipairs({ ... }) do total = total + value end
return total, select("#", ...)
end
print(sum(1, 2, 3))def sum_values(*values, **options):
total = 0
for value in values:
total += value
return total, len(values), options
print(sum_values(1, 2, 3, verbose=True))**options has no Lua counterpart at all: it collects keyword arguments into a dict. Lua approximates named arguments by passing a single options table, which is the same idea implemented by hand — and Python supports that style too.Real default arguments
Lua has no defaults, so the
or fallback stands in — with the hole the truthiness section described. Python has real defaults, applied only when the argument is genuinely absent.local function greet(name, greeting)
greeting = greeting or "Hello"
return greeting .. ", " .. name
end
print(greet("Ada"))
print(greet("Ada", "Welcome"))def greet(name, greeting="Hello"):
return greeting + ", " + name
print(greet("Ada"))
print(greet("Ada", "Welcome"))
print(greet(greeting="Welcome", name="Ada")) # by keyword, in any orderPython also checks the argument count: too few or too many is a
TypeError, where Lua silently passes nil or discards the extras. And any parameter can be passed by name, which makes a long call readable without an options table.Closures capture the variable
Both capture the enclosing variable itself and keep it alive after the outer function returns. Reading a captured variable behaves identically.
local function adder(amount)
return function(value)
return value + amount -- 'amount' is captured
end
end
local add_ten = adder(10)
print(add_ten(5))def adder(amount):
def add(value):
return value + amount # 'amount' is captured
return add
add_ten = adder(10)
print(add_ten(5))The asymmetry is assignment, not reading — as the
nonlocal row showed. Compare GDScript, which captures by value and silently does nothing; Python at least refuses to run rather than misbehaving.Decorators, which Lua does by hand
Wrapping a function is possible in both, and Lua does it by reassigning the name — which is exactly what a decorator is underneath.
-- Lua wraps a function by reassigning it.
local function greet(name) return "Hello, " .. name end
local function with_logging(fn)
return function(...)
print("calling")
return fn(...)
end
end
greet = with_logging(greet)
print(greet("Ada"))def with_logging(fn):
def wrapper(*args, **kwargs):
print("calling")
return fn(*args, **kwargs)
return wrapper
@with_logging
def greet(name):
return "Hello, " + name
print(greet("Ada"))The
@ syntax is sugar for greet = with_logging(greet), so a Lua programmer already understands the mechanism. What the syntax buys is that the wrapping is declared at the definition rather than somewhere below it, which is why decorators show up everywhere in Python frameworks.Classes, and self in Both
class, instead of the metatable pattern
Lua has no classes, so the constructor-plus-metatable pattern is written by hand in every Lua codebase. Python has
class, and the correspondence is unusually direct.local Counter = {}
Counter.__index = Counter
function Counter.new()
return setmetatable({ count = 0 }, Counter)
end
function Counter:increment()
self.count = self.count + 1
end
local counter = Counter.new()
counter:increment()
print(counter.count)class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
counter = Counter()
counter.increment()
print(counter.count)Counter.__index = Counter is what a Python class already is, setmetatable is what calling the class does, and __init__ is Counter.new. The mechanism is the same lookup-fallback idea; Python simply names the parts.self is an explicit parameter in both
This is the happiest correspondence on the page. Lua's colon is sugar that inserts
self as a first parameter, and Python does the same thing — except it never hides it.local Greeter = {}
Greeter.__index = Greeter
function Greeter.new(name)
return setmetatable({ name = name }, Greeter)
end
-- The colon adds 'self' as a hidden first parameter.
function Greeter:greet()
return "Hello, " .. self.name
end
-- Written with a dot, it is visible:
function Greeter.greet_explicit(self)
return "Hello, " .. self.name
end
local greeter = Greeter.new("Ada")
print(greeter:greet(), Greeter.greet_explicit(greeter))class Greeter:
def __init__(self, name):
self.name = name
def greet(self): # 'self' is ALWAYS written out
return "Hello, " + self.name
greeter = Greeter("Ada")
print(greeter.greet(), Greeter.greet(greeter))So
greeter.greet() and Greeter.greet(greeter) are the same call in Python, exactly as greeter:greet() and Greeter.greet(greeter) are in Lua. A Lua programmer already understands why self is there; Python just removes the colon-versus-dot choice by always requiring it.Inheritance
Both resolve a missing member by following a chain, so inheritance is the same idea. The difference is how much of the chain you build by hand.
local Animal = {}
Animal.__index = Animal
function Animal.new(name) return setmetatable({ name = name }, Animal) end
function Animal:speak() return self.name .. " makes a sound" end
local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog
function Dog.new(name) return setmetatable(Animal.new(name), Dog) end
function Dog:speak() return self.name .. " barks" end
print(Dog.new("Rex"):speak())class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return self.name + " makes a sound"
class Dog(Animal):
def speak(self):
return self.name + " barks"
print(Dog("Rex").speak())The Lua version needs two
setmetatable calls and an __index at each level, and getting one wrong yields a silent nil. Python's parenthesized base does the wiring, and super().speak() reaches the parent — which in Lua means Animal.speak(self). Python also supports multiple inheritance, which Lua can only approximate with an __index function.Dataclasses replace the boilerplate constructor
Writing a constructor, an equality test and a printable form is mechanical work in Lua, repeated for every small value type.
local Point = {}
Point.__index = Point
function Point.new(x, y)
return setmetatable({ x = x, y = y }, Point)
end
Point.__eq = function(left, right)
return left.x == right.x and left.y == right.y
end
print(Point.new(3, 4).x)
print(Point.new(1, 2) == Point.new(1, 2))from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print(Point(3, 4).x)
print(Point(1, 2) == Point(1, 2)) # __eq__ generated for you
print(Point(3, 4)) # __repr__ tooA
@dataclass generates __init__, __eq__ and __repr__ from the annotated fields. It is a decorator, so it is the previous section's mechanism applied to a class — nothing new, just a very good use of it.Neither language has private members
Neither language can hide an attribute. Lua's only real privacy is a closure over a local, as on the left, and Python relies on a naming convention.
-- Privacy in Lua is a closure, not a keyword.
local function make_account(balance)
local account = {}
function account.deposit(amount) balance = balance + amount end
function account.balance_of() return balance end
return account
end
local account = make_account(100)
account.deposit(50)
print(account.balance_of())class Account:
def __init__(self, balance):
self._balance = balance # underscore = "please don't"
def deposit(self, amount):
self._balance += amount
def balance_of(self):
return self._balance
account = Account(100)
account.deposit(50)
print(account.balance_of(), account._balance) # still reachableA single leading underscore means "internal, do not touch" and is enforced by nothing. A double underscore triggers name mangling, which makes accidental collision unlikely but is still not privacy. The Lua closure approach is genuinely stronger — at the cost of one closure per instance.
Callable tables and callable objects
Lua makes a table callable with the
__call metamethod. Python does exactly the same thing with a __call__ method — the names differ by one underscore.local adder = setmetatable({ amount = 10 }, {
__call = function(self, value) return value + self.amount end,
})
print(adder(5))
print(adder.amount)class Adder:
def __init__(self, amount):
self.amount = amount
def __call__(self, value):
return value + self.amount
adder = Adder(10)
print(adder(5))
print(adder.amount)This is the first of many one-to-one correspondences between metamethods and dunder methods, which the next section takes up properly. It is the reason Python's object model feels immediately legible to a Lua programmer.
Metatables Become Dunder Methods
__index becomes __getattr__
Lua's
__index is consulted when a key is absent from the table, and it may be a table to fall back to or a function to call. Python splits that into class inheritance (the table form) and __getattr__ (the function form).local defaults = { color = "black" }
local pen = setmetatable({}, { __index = defaults })
print(pen.color) -- black: found on the fallback
print(rawget(pen, "color")) -- nil: not on the object itselfclass Pen:
def __getattr__(self, name): # called ONLY when normal lookup fails
return {"color": "black"}.get(name)
pen = Pen()
print(pen.color) # black
print(pen.__dict__.get("color")) # None: not on the instanceThe parallel to
rawget is instance.__dict__, which reads the instance's own attributes without triggering any fallback. Note __getattr__ fires only after normal lookup fails, which is __index's behavior exactly; __getattribute__ intercepts every access and has no Lua equivalent.__newindex becomes __setattr__
Lua's
__newindex intercepts assignment to an absent key, which is how read-only tables and change tracking are built. Python's __setattr__ does the same job.local readonly = setmetatable({}, {
__index = { value = 1 },
__newindex = function()
error("read-only table", 0)
end,
})
print(readonly.value)
print(pcall(function() readonly.value = 2 end))class ReadOnly:
def __init__(self):
object.__setattr__(self, "value", 1)
def __setattr__(self, name, value):
raise AttributeError("read-only object")
readonly = ReadOnly()
print(readonly.value)
try:
readonly.value = 2
except AttributeError as error:
print(False, error)One difference matters:
__newindex fires only for keys the table does not already have, while __setattr__ fires for every assignment. That is why the constructor has to go around it with object.__setattr__ to set the initial value at all.Operator overloading, in both
Both languages let a type redefine what an operator means, and the hooks line up almost name for name — which makes Python the most familiar object model of any target on this anchor.
local Vector = {}
Vector.__index = Vector
Vector.__add = function(left, right)
return setmetatable({ x = left.x + right.x }, Vector)
end
Vector.__tostring = function(self) return "Vector(" .. self.x .. ")" end
local sum = setmetatable({ x = 1 }, Vector) + setmetatable({ x = 2 }, Vector)
print(tostring(sum))class Vector:
def __init__(self, x):
self.x = x
def __add__(self, other):
return Vector(self.x + other.x)
def __str__(self):
return f"Vector({self.x})"
print(str(Vector(1) + Vector(2)))The mapping is
__add→__add__, __sub→__sub__, __eq→__eq__, __lt→__lt__, __len→__len__, __call→__call__, __tostring→__str__. Compare JavaScript and GDScript, neither of which can overload an operator at all.__len and __len__
Both let a type answer the length operator. The Lua version is
#bag and the Python version is len(bag).local Bag = {}
Bag.__index = Bag
Bag.__len = function(self) return #self.items end
local bag = setmetatable({ items = { 1, 2, 3 } }, Bag)
print(#bag)class Bag:
def __init__(self, items):
self.items = items
def __len__(self):
return len(self.items)
bag = Bag([1, 2, 3])
print(len(bag))
print(bool(Bag([]))) # False -- __len__ also decides truthiness!The extra consequence in Python is worth knowing: with no
__bool__ defined, __len__ decides truthiness, so an object of length zero is falsy. That is the truthiness rule from earlier reaching all the way into your own classes — and it has no Lua parallel, since a Lua table is always truthy.Making your own type iterable
A generic
for in Lua calls a function repeatedly until it returns nil, so any closure of the right shape is an iterator. Python has an explicit protocol built on __iter__.-- Lua: return a closure that yields the next value each call.
local function countdown(from)
local current = from + 1
return function()
current = current - 1
if current > 0 then return current end
end
end
for value in countdown(3) do io.write(value, " ") end
print()class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
current = self.start
while current > 0:
yield current
current -= 1
print(" ".join(str(value) for value in Countdown(3)))The neat part is that
__iter__ can be a generator — the yield keyword — so you write the loop directly rather than manufacturing a closure over mutable state. Ending is explicit too: a generator returning is the signal, where Lua overloads nil.pcall Becomes try/except
pcall becomes try/except
Both unwind the stack to a handler, so the model is shared. Lua wraps the risky code in a function and gets a status back; Python uses a statement and binds the raised object.
local ok, message = pcall(function()
error("something broke", 0)
end)
print(ok, message)
print("execution continues")try:
raise ValueError("something broke")
except ValueError as error:
print(False, error)
print("execution continues")Because
pcall returns rather than branching, Lua code checks a boolean where Python code nests a block. Python also has else (ran without raising) and finally (always runs) — the latter having no real Lua equivalent before 5.4's <close>, which Fengari does not implement.Exceptions have types you can select on
Lua can raise any value, including a table carrying structured data — but there is no dispatch: a single
pcall catches everything and you inspect the value by hand.-- Lua raises any value; distinguishing them is manual.
local ok, thrown = pcall(function()
error({ code = 404, kind = "not_found" })
end)
if not ok and type(thrown) == "table" and thrown.kind == "not_found" then
print("not found", thrown.code)
endclass NotFoundError(Exception):
def __init__(self, code):
super().__init__(f"not found: {code}")
self.code = code
try:
raise NotFoundError(404)
except NotFoundError as error:
print("not found", error.code)
except Exception:
print("something else")Python selects the handler by exception class, so unrelated failures are not swallowed by a handler meant for something else. That is the practical difference:
pcall is all-or-nothing, and a bare except Exception is the Python equivalent that experienced code avoids.assert
Both languages spell it the same way and both raise something catchable — a close match, with one important caveat.
local function withdraw(balance, amount)
assert(amount > 0, "amount must be positive")
return balance - amount
end
print(withdraw(100, 30))
print(pcall(withdraw, 100, -5))def withdraw(balance, amount):
assert amount > 0, "amount must be positive"
return balance - amount
print(withdraw(100, 30))
try:
withdraw(100, -5)
except AssertionError as error:
print(False, error)Python's
assert is a statement removed entirely when the interpreter runs with -O, so it must never validate input that could legitimately be wrong. Lua's is an ordinary function that is always present, and is therefore safe in shipping code — the reverse of the usual expectation.with, which Lua has no equivalent of
Releasing a resource reliably in Lua 5.3 means wrapping the use in a
pcall and remembering to clean up on both paths. Python has a language construct for it.-- Lua 5.3 has no scoped cleanup; you do it by hand,
-- and a pcall is needed to make it reliable.
local resource = { open = true }
local ok = pcall(function()
error("failed while using it", 0)
end)
resource.open = false -- cleanup, remembered manually
print(ok, resource.open)class Resource:
def __enter__(self):
self.open = True
return self
def __exit__(self, *details):
self.open = False
return False # do not suppress the exception
resource = Resource()
try:
with resource:
raise RuntimeError("failed while using it")
except RuntimeError:
pass
print(False, resource.open) # cleanup ran anywayA
with block calls __exit__ however the block ends — normally, by exception, or by return — which is what makes with open(path) as file the universal Python idiom. Lua 5.4's <close> attribute is the closest equivalent, and Fengari does not implement it.Coroutines Become Generators
coroutine.yield becomes yield
Generators are Python's coroutines, and the correspondence is close: a function that suspends mid-body and resumes where it left off.
local function counter()
for index = 1, 3 do
coroutine.yield(index)
end
end
local routine = coroutine.create(counter)
print(select(2, coroutine.resume(routine)))
print(select(2, coroutine.resume(routine)))
print(select(2, coroutine.resume(routine)))def counter():
for index in range(1, 4):
yield index
routine = counter()
print(next(routine))
print(next(routine))
print(next(routine))Two differences in the wrapping. Any function containing
yield is automatically a generator — there is no coroutine.create — and calling it returns the generator object without running any of the body. next() is coroutine.resume, and exhaustion raises StopIteration rather than returning false.Passing values back in
Both are two-way channels: the value sent in becomes the result of the suspended
yield expression. This is what makes them coroutines rather than mere iterators.local function echo()
local received = coroutine.yield("ready")
coroutine.yield("got " .. received)
end
local routine = coroutine.create(echo)
print(select(2, coroutine.resume(routine)))
print(select(2, coroutine.resume(routine, "hello")))def echo():
received = yield "ready"
yield "got " + received
routine = echo()
print(next(routine))
print(routine.send("hello"))Python splits resumption into
next() (send nothing) and send(value), where Lua uses coroutine.resume for both. The first resume must be next() or send(None), because there is no suspended yield waiting to receive a value yet — the same rule Lua has, where the first resume's extra arguments become the function's parameters instead.Generators drive a for loop
Lua's
coroutine.wrap turns a coroutine into a plain function returning the next value, which is the shape a generic for wants. Python generators satisfy the iteration protocol directly.local function range_up_to(limit)
return coroutine.wrap(function()
for index = 1, limit do coroutine.yield(index) end
end)
end
for value in range_up_to(3) do
io.write(value, " ")
end
print()def range_up_to(limit):
for index in range(1, limit + 1):
yield index
print(" ".join(str(value) for value in range_up_to(3)))So both use a suspendable function to drive a loop, and Python needs no wrapper. The termination signal differs: Lua's loop stops on
nil, which means a genuine nil in your data ends the loop early; Python raises StopIteration, so None is a perfectly valid value to yield.Laziness is idiomatic here
Lua has coroutines but no culture of lazy sequences — the standard library offers nothing that consumes one, so building the table first is the normal thing to do.
-- Lua builds the whole table, then walks it.
local squares = {}
for index = 1, 5 do squares[index] = index * index end
local total = 0
for _, value in ipairs(squares) do total = total + value end
print(total)# The generator expression never builds a list at all.
total = sum(value * value for value in range(1, 6))
print(total)
# It works on an unbounded source too:
def naturals():
value = 1
while True:
yield value
value += 1
from itertools import islice
print(list(islice(naturals(), 5)))Python's generator expression is a comprehension that produces values on demand, and the whole
itertools module is built to consume them. That makes an infinite sequence an ordinary tool rather than a curiosity, which is a genuinely different way of writing loops.require Becomes import
require becomes import
Both load a module once, cache it, and give you access to its contents. Lua returns a table you assign yourself; Python has statement syntax that binds names for you.
local math_library = require("math")
print(math_library.floor(3.7))
-- Pull individual names off the returned table by hand:
local floor, maximum = math_library.floor, math_library.max
print(floor(3.7), maximum(1, 9))import math
print(math.floor(3.7))
from math import floor, fmod
print(floor(3.7), fmod(7, 3))require is an ordinary function, so a Lua module path can be computed at run time; import is a statement, which is what lets tooling see the dependency graph. Python also has importlib.import_module for the dynamic case.A module is a file in both
A Lua module is a file that returns a value, so its interface is whatever table you hand back. A Python module exposes its top-level names directly, with nothing to return.
-- A Lua module ends by returning a value, usually a table.
local geometry = {}
function geometry.area(width, height) return width * height end
-- return geometry
print(geometry.area(3, 4))# A Python module returns nothing; its top-level names ARE its
# contents, and 'import geometry' gives you the module object.
def area(width, height):
return width * height
print(area(3, 4))
print(__name__) # "__main__" when run directly, the module name when importedThat makes the Lua version explicit about its interface and the Python version explicit about nothing — the convention is a leading underscore for internals, and
__all__ to declare what from module import * exports. The __name__ == "__main__" test is Python's way of writing a file that is both importable and runnable.The standard library is enormous
Lua's standard library is nine tables, and that is deliberate — the interpreter is meant to fit inside a host program. Python's ships hundreds of modules.
-- Lua's ENTIRE standard library: string, table, math, io,
-- os, coroutine, utf8, debug, package. That is all of it.
print(type(string), type(table), type(math), type(coroutine))import json, datetime, collections, itertools, functools
print(json.dumps({"a": 1}))
print(collections.Counter("aabbbc").most_common(1))
print(list(itertools.accumulate([1, 2, 3])))
print(functools.reduce(lambda running, value: running + value, [1, 2, 3]))The difference in philosophy is the whole story of this page compressed into one row. Lua expects the host or LuaRocks to supply what you need; Python expects it to be there already. Neither is wrong, but it changes what "write a script" means.
Gotchas for Lua Developers
A length check inverts
The truthiness rule in the shape that appears most in real code, and which reads as correct in both languages.
local items = {}
if #items then
print("this ALWAYS runs in Lua: 0 is truthy")
enditems = []
if len(items):
print("never runs: 0 is falsy")
else:
print("this runs in Python")
if not items: # the idiomatic emptiness test
print("and this is how it is normally written")In Lua the guard is meaningless —
#items is a number and every number is truthy. In Python it is the idiomatic emptiness test, usually written as if not items: without the len at all.🚨 A mutable default argument is shared
This is Python's most famous surprise and it has no Lua counterpart, because Lua has no default arguments at all — the
or idiom happens to build a fresh table each call.-- Lua's 'or' idiom builds a fresh table every call.
local function collect(item, into)
into = into or {}
into[#into + 1] = item
return into
end
print(#collect("a"), #collect("b")) -- 1 1def collect_broken(item, into=[]): # evaluated ONCE, at definition
into.append(item)
return into
print(len(collect_broken("a")), len(collect_broken("b"))) # 1 2
def collect(item, into=None): # the correct pattern
if into is None:
into = []
into.append(item)
return into
print(len(collect("a")), len(collect("b"))) # 1 1A default expression is evaluated once, when the
def executes, so a mutable default is shared by every call that omits the argument. The fix is always the same: default to None and build the real value inside. Note this is exactly the shape a Lua programmer reaches for first.is is not ==
Lua's
== on two tables compares identity — same object or not — unless an __eq metamethod says otherwise. Python's == compares contents by default, and identity has its own operator.-- Lua has one equality operator, and tables compare by identity.
local left = { 1, 2 }
local right = { 1, 2 }
print(left == right) -- false: different tables
print(left == left) -- trueleft = [1, 2]
right = [1, 2]
print(left == right) # True: compares CONTENTS
print(left is right) # False: different objects
print(left is left) # TrueSo the Lua habit of "
== means same table" translates to is, not ==. Using is for numbers or strings appears to work because of interning and then fails on larger values, which is why it is reserved for None, True and False.A list is not a dict with integer keys
A Lua table with scattered integer keys is perfectly ordinary — it is the same structure either way, and only
# gets confused. Python makes you choose the type up front.-- One table, so this is natural and legal.
local sparse = {}
sparse[1] = "a"
sparse[100] = "b"
print(sparse[1], sparse[100])# A list cannot be sparse; assigning past the end raises.
sparse = ["a"]
# sparse[99] = "b" # <- IndexError: list assignment index out of range
sparse_dict = {1: "a", 100: "b"} # use a dict for sparse integer keys
print(sparse_dict[1], sparse_dict[100])A list is a contiguous sequence and cannot grow by assignment to an arbitrary index;
append is the only way to extend it. When the keys really are sparse integers, a dict is the correct structure and behaves the way the Lua table did.The loop variable outlives the loop
Lua scopes the numeric loop variable to the loop, so it is gone afterwards. Python has no block scope, so the name persists — and if it already existed, it has been overwritten.
for index = 1, 3 do
-- 'index' exists only inside the loop
end
print(index) -- nilfor index in range(1, 4):
pass
print(index) # 3 -- still bound after the loop
value = "before"
for value in ["a", "b"]:
pass
print(value) # "b" -- the outer name was overwrittenReusing a common name like
value or index as a loop variable therefore clobbers whatever was there. Comprehensions are the exception: their variable is scoped to the comprehension and does not leak, which is one more reason to prefer them.