luaguides

Variables and Types in Lua: A Complete Guide

Lua variables and types form the foundation of every program you’ll write. Lua is dynamically typed, which means you never need to declare a variables types before using it — the type is determined at runtime based on the value you assign. This makes Lua flexible and beginner-friendly, though understanding the eight basic types and how variable scoping works will help you write cleaner, faster code.

Variables: local versus global

In Lua, variables are containers for values. By default, variables are global unless you explicitly declare them as local.

Global Variables

Global variables are accessible from anywhere in your program:

-- This creates a global variable
message = "Hello, Lua!"
print(message)  -- Hello, Lua!

-- You can also use bracket syntax for global access
_G.message = "Hello again!"

Global variables persist throughout your program but can cause naming conflicts in larger projects. Use them sparingly.

When you declare a variable without local, Lua stores it in the global table, equivalent to writing _G.varname = value. This table lookup is slower than a local variable access because the interpreter must perform a hash-table lookup rather than reading from a register or stack slot. In tight loops, using globals can degrade performance noticeably. A common Lua pitfall is accidentally creating a global inside a function by forgetting the local keyword, which pollutes the global namespace and can cause hard-to-debug interactions between unrelated parts of your program.

Local Variables

Local variables are declared with the local keyword and are only accessible within the block where they are defined:

local count = 10
local name = "Lua"

function greet()
    local greeting = "Hi there"  -- local to this function
    print(greeting .. ", " .. name)
end

greet()  -- Hi there, Lua!
-- print(greeting)  -- Error: greeting is not defined

Always prefer local variables in Lua. They are faster to access and prevent unintended interactions between different parts of your code.

Lua’s scoping rules differ from many languages: a variable declared local inside a doend block, a function body, or a control structure (if, for, while) is visible only within that block and any nested blocks inside it. Outside the block, the name resolves to nil; Lua does not throw an error for reading an undefined variable, which means misspelling a local name silently produces nil instead of a helpful diagnostic. This is why many Lua developers adopt naming conventions and use strict linting tools to catch these silent failures before they cause runtime problems.

The _ENV Variable

In Lua 5.2 and later, global variables are actually entries in the _ENV table:

print(_ENV == _G)  -- true (in the main chunk)

local env = {}
_G = env  -- Redirects global access to a new table

env.x = 10
print(x)  -- 10

Understanding _ENV becomes important when you want to control what globals are available, particularly when embedding Lua.

Lua’s eight basic types

Lua has eight basic types: nil, boolean, number, string, function, table, userdata, and thread.

nil

nil represents the absence of a value. Uninitialized variables default to nil:

local x
print(x)  -- nil

local y = nil
print(y)  -- nil

Since every uninitialized variable in Lua starts as nil, nil checks are one of the most frequent patterns you’ll write. Functions that do not execute an explicit return statement return nil by default, and table lookups for nonexistent keys also produce nil; there is no separate “undefined” or “null” type in Lua, so nil serves double duty for both “no value” and “intentionally empty.” This dual role means you cannot always tell from a nil result whether a key was absent or the stored value was explicitly nil, which occasionally calls for sentinel values when you need to distinguish those two cases.

local value = some_function()

if value == nil then
    print("No value returned")
end

Boolean

The nil type is a singleton: there is exactly one nil value in the entire Lua runtime. Unlike some languages where null compares only with special operators, you can compare against nil with plain == and ~=. This simplicity extends to how nil interacts with tables: assigning nil to a table key removes that key entirely, which is how you delete entries from a Lua table. Understanding nil’s uniqueness prepares you for boolean logic, where knowing what is and isn’t falsy becomes essential for control flow.

Boolean values are true and false. In Lua, only nil and false are falsy; everything else is truthy, including zero and empty strings:

local is_active = true
local is_empty = false

if is_active then
    print("Active!")  -- This runs
end

if 0 then
    print("Zero is truthy")  -- This runs!
end

if "" then
    print("Empty string is truthy")  -- This runs!
end

Number

The truthiness rules in Lua mean that common patterns from other languages don’t translate directly. Writing if not str then to check for an empty string fails because "" is truthy; you’d need if str == "" or str == nil then instead. Likewise, if count then does not catch zero; a variable holding 0 passes the truthiness test. These surprises arise from Lua’s minimal falsy set, which keeps the language small but expects you to reach for explicit comparisons when zero or empty strings need special treatment.

Lua uses a single number type that represents both integers and floating-point numbers. Internally, this is typically a 64-bit double:

local integer = 42
local floating = 3.14159
local negative = -10
local scientific = 6.02e23

print(type(integer))  -- number
print(type(floating))  -- number

Lua 5.3+ distinguishes between integers and floats when needed, but you rarely need to think about this distinction in everyday code.

Lua’s number type can represent any 64-bit integer exactly. The integer subtype introduced in 5.3 is not a separate type but a storage optimization that the runtime uses transparently. When you write local n = 42, Lua stores it as an integer internally, and arithmetic with two integers stays in the integer domain until a fractional result forces a float conversion. This hybrid representation means Lua can handle large exact counts without floating-point drift, a subtle advantage for game scripting and embedded systems where Lua sees heavy use.

String

Strings in Lua are immutable sequences of bytes. You can use single quotes, double quotes, or long brackets:

local single = 'Hello'
local double = "Hello"
local multiline = [[Hello
World]]
local escaped = "He said, \"Lua is great!\""

print(#single)  -- 5 (the # operator gives string length)

The # operator determines string length by counting bytes, not characters; for ASCII text these are the same, but with UTF-8 encoded strings containing multibyte characters, # can report a larger number than the visible character count. Lua strings are interned, meaning identical string literals share the same underlying storage, which makes string comparison with == an O(1) pointer comparison rather than a character-by-character scan. This interning also means that building large strings by repeated concatenation in a loop can be expensive, since each .. operation creates a new string. For bulk string construction, place fragments in a table and call table.concat() at the end.

print(#"Lua")       -- 3
print(#"")          -- 0
print(#"Hello")     -- 5

Function

Lua’s string library provides a rich set of operations: string.sub() for slicing, string.find() for pattern matching, string.gsub() for global substitution, and string.format() for printf-style formatting. When you call a string method with the colon syntax like s:find("l"), Lua passes the string as the first argument automatically. The patterns used by string.find() and string.gsub() are Lua patterns, not full regular expressions; they lack alternation (|) and some quantifiers, but they are lighter and faster for the common cases Lua targets.

Functions in Lua are first-class values. They can be stored in variables, passed as arguments, and returned from other functions:

local function greet(name)
    return "Hello, " .. name .. "!"
end

print(greet("Lua"))  -- Hello, Lua!

-- Functions as values
local say_hello = function()
    print("Hi!")
end

say_hello()  -- Hi!

Table

Functions and tables are deeply intertwined in Lua. A function stored as a table field becomes a method when called with the colon syntax obj:method(), which passes obj as the implicit first parameter self. Lua uses this pairing to implement object-oriented patterns: a table holds the fields, and functions attached to that table provide the behavior. Functions are also closures: they capture the local variables visible at their definition point, which means a function created inside another function can outlive its enclosing scope while still accessing those captured variables. This closure mechanism is what powers iterators, callbacks, and many of Lua’s most expressive idioms.

Tables are Lua’s only composite data structure. They implement arrays, dictionaries, objects, and more:

-- Array-like table
local fruits = {"apple", "banana", "cherry"}
print(fruits[1])  -- apple (tables are 1-indexed!)

-- Dictionary-like table
local person = {
    name = "Alice",
    age = 30,
    city = "London"
}
print(person.name)  -- Alice

-- Mixed
local mixed = {
    "first",
    second = "value",
    3
}

Userdata

Tables store data on the Lua heap and are garbage-collected like every other Lua value. They resize dynamically; an array with ten thousand entries works the same as one with two, and you can mix integer-indexed array slots with string-keyed dictionary slots in the same table without performance penalties for most workloads. Lua’s table implementation uses separate array and hash parts internally, so sequential integer keys from 1 upward are stored in a true array segment that provides O(1) access, while string keys and sparse integer keys go into the hash part. Understanding this dual-storage model helps you design data structures that play to Lua’s strengths.

Userdata represents raw C data. It allows Lua to interface with C structures and is primarily used when embedding Lua in C programs:

-- You cannot create userdata from pure Lua
-- It comes from the C host program
print(type(obj))  -- userdata (when obj is a userdata value)

Thread

Userdata comes in two flavors: full userdata, which allocates a block of raw memory managed by Lua’s garbage collector and can have an associated metatable for operator overloading; and light userdata, which is simply a C pointer value stored as a Lua value with no allocation or garbage-collection involvement. Light userdata is useful when you need to pass an opaque C pointer through Lua without the overhead of managed memory. You will rarely create userdata from Lua code; it’s the C API’s way of exposing foreign objects to your scripts, and recognizing it helps you understand stack traces and type errors in embedded environments.

Threads represent coroutines, Lua’s lightweight concurrency mechanism:

local co = coroutine.create(function()
    print("Running")
end)

print(type(co))  -- thread

Type Checking with type()

A Lua thread is not an operating-system thread. It runs cooperatively inside a single OS thread, yielding control explicitly with coroutine.yield() and resuming via coroutine.resume(). This means coroutines never execute in parallel; they take turns, which eliminates race conditions and locking entirely. Lua coroutines are stackful, so you can yield from deep inside nested function calls, and the full call stack is preserved until the next resume. This makes them ideal for implementing state machines, generators, and non-blocking I/O schedulers without the complexity of preemptive threading.

The type() function returns the type of a value as a string:

print(type(nil))         -- nil
print(type(true))        -- boolean
print(type(42))          -- number
print(type("hello"))     -- string
print(type({}))          -- table
print(type(print))       -- function
print(type(io.stdin))    -- userdata (file handle)
print(type(coroutine.create(function() end)))  -- thread

The output of type() is always a lowercase string: "nil", "boolean", "number", "string", "function", "table", "userdata", or "thread". Since Lua does not have a built-in instanceof or class-hierarchy check, comparing type() return values against these string constants is the canonical way to branch on the kind of value you’re holding. Be careful with userdata: type() cannot tell you what kind of C data sits behind a userdata value; all full userdata and light userdata both report "userdata", so libraries typically provide their own type-testing functions when the distinction matters.

local value = get_some_value()

if type(value) == "string" then
    print("Got a string: " .. value)
elseif type(value) == "number" then
    print("Got a number: " .. value)
end

Type Coercion

Relying on type() for branching is common in Lua APIs that accept multiple input kinds. For instance, a function that can take either a string filename or a file handle object dispatches with if type(x) == "string" then ... elseif type(x) == "userdata" then ... end without needing polymorphism through metatables, though metatable-based approaches scale better for larger type hierarchies. Note that type() on a value with a metatable still returns the underlying primitive type; metatables can change behavior but not the reported type, so you cannot use type() to detect custom “classes.”

Lua automatically converts between strings and numbers when needed:

-- Arithmetic operations coerce strings to numbers
local result = "10" + 5  -- 15 (string "10" becomes number 10)
local concat = 10 .. 20  -- "1020" (number becomes string)

-- Explicit conversion
local num = tonumber("42")     -- 42
local str = tostring(100)      -- "100"

-- Common pitfall
print("10" == 10)    -- false! Different types
print(tonumber("10") == 10)  -- true

Be aware of this when comparing values across types.

Automatic coercion can surprise you when values come from external input. Reading from a file or parsing JSON produces strings, and if you then perform arithmetic on them, Lua coerces silently: " 42 " + 1 yields 43.0 because tonumber-style coercion strips whitespace and accepts both integer and decimal formats. Coercion failures produce an error: "hello" + 1 raises attempt to perform arithmetic on a string value. For safety, call tonumber() explicitly and check for nil before operating on values whose origin you don’t control. The .. concatenation operator coerces numbers to strings, but be mindful that 10 .. 20 produces "1020". Adding parentheses or spaces around .. operations makes intent clearer when mixing arithmetic and concatenation in the same expression.

Best Practices

  1. Always use local unless you explicitly need a global variable
  2. Initialize your variables — uninitialized variables are nil
  3. Use meaningful namescount is better than x
  4. Be aware of type coercion when comparing values
  5. Use type() to check types instead of relying on implicit behavior

Next steps

Now that you understand variables and types, the next tutorial in this series covers Control Flow in Lua — how to make decisions with if statements and loop with for, while, and repeat.

See also