Skip to content

Basics

Lua has eight types and no type declarations. Most of the surprises for people arriving from other languages come from three places: globals are the default, nil is a real value with real consequences, and numbers quietly split into integers and floats in Lua 5.3+.

type(v) returns one of exactly eight strings. There are no others.

print(type(nil)) -- => nil
print(type(true)) -- => boolean
print(type(42)) -- => number
print(type("hi")) -- => string
print(type(print)) -- => function
print(type({})) -- => table
print(type(io.stdout)) -- => userdata
print(type(coroutine.create(function() end))) -- => thread
Type What it is
nil The absence of a value. Only one value: nil.
boolean true and false.
number Integers and floats (see below).
string Immutable, 8-bit clean byte strings.
function Lua functions and C functions, indistinguishable from Lua.
table Associative arrays. The only composite type.
userdata A block of raw C memory owned by the host. You can’t create one in pure Lua.
thread A coroutine. Not an OS thread.

Values are typed; variables are not. A variable is just a name bound to a value, and it can be rebound to a value of any type.

local x = 10
x = "now a string"
x = function() return 1 end
x = { 1, 2, 3 } -- all legal

nil, boolean and number are value types — assignment copies them. string, function, table, userdata and thread are reference types; assignment copies the reference. Strings are immutable so the distinction is invisible for them.

local a = { 1, 2 }
local b = a
b[1] = 99
print(a[1]) -- => 99 (same table)

userdata is how a host program hands you a C object: a file handle from io.open, a socket, a game entity. It is opaque — you interact with it only through methods the host installs via a metatable.

local f = io.open("/etc/hostname", "r")
print(type(f)) -- => userdata
f:close()

thread is a coroutine, covered on the standard library page. Lua has no OS threads and no shared-memory concurrency in the core language.

-- a line comment
--[[ a block comment
spanning lines ]]
--[==[ a block comment that can contain ]] safely ]==]

The [==[ ... ]==] form uses matching numbers of = signs, which is how you nest or embed brackets. A useful trick: adding one - toggles a block of code on and off.

--[[
print("disabled")
--]]
---[[
print("enabled") -- the leading --- makes the opener a line comment
--]]

An unqualified assignment creates a global. This is the single most consequential default in the language.

count = 0 -- global
local count = 0 -- local to the enclosing block/chunk

Globals are not a separate namespace mechanism — they are entries in a table. Every function has an upvalue named _ENV, and the compiler rewrites count into _ENV.count. At the top level _ENV is the global table _G.

x = 1
print(_G.x) -- => 1
print(_ENV.x) -- => 1
_G.y = 2
print(y) -- => 2

That means every global read is a hash table lookup, while a local is a slot in the function’s register frame. Locals are meaningfully faster in hot code, and much friendlier to LuaJIT.

The standard mitigation is to cache library functions in locals at the top of a module, which is both faster and self-documenting:

local fmt = string.format
local insert = table.insert
local floor = math.floor

A local is visible from after its declaration statement to the end of the enclosing block. Blocks are created by do ... end, function bodies, loop bodies, and if branches.

local v = 1
do
local v = 2
print(v) -- => 2
end
print(v) -- => 1

Because visibility starts after the declaration, this does not do what it looks like:

local x = 10
local x = x + 1 -- the x on the right is the OLD x
print(x) -- => 11

And recursion needs local function, which declares the name before compiling the body:

local function fact(n)
if n <= 1 then return 1 end
return n * fact(n - 1) -- works
end
-- the desugared form does NOT work:
local fact2 = function(n) return n * fact2(n - 1) end -- fact2 is nil inside

Lua 5.4 adds two attributes in angle brackets:

local MAX <const> = 100
-- MAX = 200 --> compile error: attempt to assign to const variable 'MAX'
local f <close> = io.open("data.txt")
-- f's __close metamethod runs when the block exits, even on error

<const> is checked at compile time and lets the compiler inline the value. <close> is for deterministic cleanup: the value’s __close metamethod is called when the variable goes out of scope. Neither exists in 5.1/5.3.

nil is the value of anything that has not been assigned. Reading an undeclared global or a missing table key is not an error — you get nil.

print(undefined_thing) -- => nil
local t = {}
print(t.missing) -- => nil

Assigning nil deletes a table key. There is no distinction between “key absent” and “key present with value nil”:

local t = { a = 1 }
t.a = nil
for k in pairs(t) do print(k) end -- prints nothing

Where nil bites:

  • Calling or indexing it is a runtime error: attempt to index a nil value / attempt to call a nil value. This is the most common Lua error, and it usually means a typo in a name or a failed require.
  • A nil in the middle of an array breaks # and ipairs (see Tables).
  • nil can’t be a table key at all: t[nil] = 1 raises table index is nil.

Only two values are false: nil and false. Everything else is true — including 0, "", {} and NaN.

if 0 then print("zero is true") end -- prints
if "" then print("empty is true") end -- prints
if not nil then print("nil is false") end -- prints

This is a genuine simplification compared to JavaScript or Python, but it means you must test explicitly for emptiness:

if #s == 0 then ... end -- empty string
if next(t) == nil then ... end -- empty table

Comparison operators always return a real boolean. == compares references for tables/functions and values for numbers/strings; it never coerces between types:

print(1 == "1") -- => false (no coercion in comparison)
print("a" < "b") -- => true (byte order, locale-dependent for non-ASCII)

Comparing values of different types with < is an error, not false:

-- print(1 < "2") --> error: attempt to compare number with string

Since Lua 5.3 a number is either an integer (64-bit signed) or a float (C double). Both report type(x) == "number"; math.type tells them apart.

print(math.type(3)) -- => integer
print(math.type(3.0)) -- => float
print(math.type(3e0)) -- => float
print(math.type("3")) -- => nil (not a number at all)

A literal is an integer if it has no decimal point and no exponent. Arithmetic preserves the distinction, with two important exceptions:

print(7 // 2) -- => 3 floor division, stays integer
print(7 / 2) -- => 3.5 '/' ALWAYS produces a float
print(4 / 2) -- => 2.0 even when exact
print(2 ^ 10) -- => 1024.0 '^' ALWAYS produces a float
print(7 % 2) -- => 1
print(-7 // 2) -- => -4 floor, not truncation
print(-7 % 2) -- => 1 result takes the sign of the divisor

Mixing an integer and a float promotes to float. Equality compares mathematical value, so 1 == 1.0 is true — but tostring distinguishes them:

print(1 == 1.0) -- => true
print(tostring(1)) -- => 1
print(tostring(1.0)) -- => 1.0
print(string.format("%d", 3.0)) -- => 3 (3.0 has an exact integer value)

Integers wrap around on overflow; floats go to infinity:

print(math.maxinteger + 1 == math.mininteger) -- => true
print(math.huge, -math.huge) -- => inf -inf
print(1/0) -- => inf (float division by zero is fine)
-- print(1//0) --> error: attempt to perform 'n//0'

Convert deliberately:

print(math.tointeger(3.0)) -- => 3
print(math.tointeger(3.5)) -- => nil (fails)
print(math.floor(3.7)) -- => 3 (returns an integer)
print(tonumber("0x1f")) -- => 31
print(tonumber("ff", 16)) -- => 255
print(tonumber("nope")) -- => nil

Literal forms: 42, 0xFF, 3.0, 1e-3, 0x1p4 (hex float, = 16.0).

& | ~ << >> and unary ~ operate on 64-bit integers. Floats with exact integer values are converted; anything else is an error.

print(0xF0 & 0x3C) -- => 48
print(1 << 10) -- => 1024
print(~0) -- => -1

Shifts are logical, not arithmetic: -1 >> 1 fills with zeros and gives a large positive number. Shifting by 64 or more yields 0.

Strings are immutable byte sequences. They are not character sequences — #s is a byte count, and Lua has no built-in Unicode string type. Lua 5.3+ ships a small utf8 library for encoding and decoding, but indexing is still byte-based.

local s = "hello"
print(#s) -- => 5
print(s .. " world") -- => hello world
print(s:upper()) -- => HELLO (method syntax works on any string)
print(("%d items"):format(3)) -- => 3 items

The s:upper() form works because every string shares a metatable whose __index is the string library — so s:upper() is string.upper(s).

Quoting forms:

local a = 'single'
local b = "double" -- identical; no interpolation in either
local c = [[
long string: keeps newlines,
ignores a leading newline, and
does no escape processing: \n stays literal
]]
local d = [==[ can contain ]] safely ]==]

Escape sequences in quoted strings: \n \t \r \\ \" \' \a \b \f \v, \ddd (up to 3 decimal digits), \xXX (hex, 5.2+), \u{XXXX} (UTF-8 codepoint, 5.3+), and \z (skip following whitespace including newlines, 5.2+).

local sql = "SELECT *\z
FROM t" -- => "SELECT *FROM t"
print("\u{2603}") -- => a snowman, encoded as UTF-8

Numbers and strings coerce in arithmetic, and numbers coerce in concatenation:

print("10" + 5) -- => 15
print(10 .. 20) -- => 1020 (a string)

Relying on string→number coercion is a bad habit; it hides bugs and behaves differently across versions. Use tonumber explicitly.

Because strings are immutable, building one in a loop with .. is O(n²). Collect pieces in a table and use table.concat:

local parts = {}
for i = 1, 1000 do parts[#parts + 1] = tostring(i) end
local s = table.concat(parts, ",")

Lua assigns lists to lists. Extra values are discarded, missing ones become nil.

local a, b, c = 1, 2
print(a, b, c) -- => 1 2 nil
local x, y = 1, 2
x, y = y, x -- swap, no temporary needed
print(x, y) -- => 2 1

The whole right-hand side is evaluated before any assignment, which is what makes the swap work.

  • Eight types, and type() returns exactly one of eight strings.
  • Assignment without local creates a global, which is a key in _ENV/_G shared by the whole process. Default to local.
  • nil means absent; assigning nil deletes a table key; nil cannot be a key.
  • Only nil and false are falsy — 0 and "" are true.
  • Lua 5.3+ numbers are integer or float. / and ^ always give floats; // gives floor division. Lua 5.1/LuaJIT have doubles only.
  • Strings are immutable bytes; use table.concat to build them in bulk.