Pull-Style Generators in Lua: Flattening Callback Code with Coroutines
Lua coroutines can act as pull-style generators, replacing nested callbacks and intermediate tables with resumable producers. This post covers a worked example, API trade-offs, and cleanup pitfalls across Lua versions.
14 Jun 2026, 22:21 UTC

Lua code that processes sequences often falls into one of two patterns: build a large intermediate table and hand it to a consumer, or nest callbacks so a producer can push values one at a time. Both approaches add allocation pressure and indentation. Coroutines offer a third path. By treating a coroutine as a resumable function, you can write a producer that pauses with coroutine.yield and a consumer that pulls values only when needed.
Why push-style code gets awkward
When a producer pushes values, the consumer must either accept them inside a callback or collect them into a table. Callbacks nest: the producer controls the flow, and the consumer loses the ability to break early or use standard loop syntax. Table collection is simpler to read, but it materializes every value in memory before the consumer sees the first one. For large or infinite sequences, that is either wasteful or impossible.
Coroutines invert the relationship. They are cooperative routines inside a single Lua state—not OS threads. A coroutine runs until it yields, hands a value back to its caller, and waits to be resumed. The caller decides when to pull the next value.
A generator you can pull from
The simplest useful generator is a numeric range. In Lua 5.1 through 5.4 and LuaJIT, coroutine.wrap creates a function that resumes a coroutine and returns whatever it yields:
local function range(n)
return coroutine.wrap(function()
for i = 1, n do
coroutine.yield(i)
end
end)
end
local sum = 0
for v in range(5) do
sum = sum + v
end
-- sum == 15
Each iteration resumes the coroutine, which runs until the next coroutine.yield. When the wrapped function returns, the iterator stops. The producer pauses at each yield; the consumer resumes it and stops when the wrapped function returns no values.
This pattern flattens code that would otherwise build a table:
-- Push style: materialize, then consume
local function build_range(n)
local t = {}
for i = 1, n do t[i] = i end
return t
end
for _, v in ipairs(build_range(5)) do
-- ...
end
The generator version allocates one coroutine instead of a table of n elements. For small ranges the difference is negligible, but for streaming file lines, paginated API responses, or tree traversals, the memory profile changes meaningfully.
Choosing between wrap and resume
coroutine.wrap is ergonomic for iterators because yielded values arrive directly at the call site. It is also less forgiving: if the generator body raises an error, wrap propagates that error into the caller. That is fine for internal helpers, but at module boundaries you may want explicit failure handling.
Use coroutine.create plus coroutine.resume when you need coroutine.status, explicit error checks, or to avoid exceptions crossing layers:
local co = coroutine.create(function()
coroutine.yield(1)
error("boom")
end)
local ok, value = coroutine.resume(co)
-- ok == true, value == 1
local ok2, err = coroutine.resume(co)
-- ok2 == false, err contains the error message
With wrap, the same error would surface at the call site unless you guard the body with pcall. Prefer resume where failures must be handled explicitly; prefer wrap for tight loops where an error should stop execution immediately.
Lifecycle, portability, and cleanup
Coroutines are cooperative. Any long-running loop without a yield—or a blocking C call—stalls the entire Lua state. Keep yields inside pure Lua control flow unless you have verified that your build supports yielding across C callbacks, FFI calls, or specific metamethods.
Version differences matter for cleanup. Lua 5.4 introduces to-be-closed variables and coroutine.close, which helps release resources when a coroutine is garbage-collected or explicitly closed. Lua 5.1–5.3 and LuaJIT do not have coroutine.close. Portable code should close files, sockets, or buffers explicitly rather than relying on coroutine finalization.
Early break is a common leak. If a generator holds a file handle and the consumer stops iterating, the coroutine is suspended, not dead, and the handle stays open. Dispose resources explicitly:
local function lines(path)
local f = io.open(path, "r")
if not f then error("cannot open " .. path) end
return coroutine.wrap(function()
for line in f:lines() do
coroutine.yield(line)
end
f:close()
end)
end
-- Consumer must handle early exit
local iter = lines("data.txt")
for line in iter do
if line == "stop" then
-- iter's coroutine is suspended; f is still open
break
end
end
-- Explicit cleanup is required here
Verifying the behavior
Before relying on coroutine generators, confirm the exact interpreter version by running lua -v or luajit -v in your shell. Run the range(5) snippet and assert sum == 15. Test an early break and check coroutine.status transitions: expect suspended after a yield and dead after normal return or error. Use luac -p file.lua in your shell to catch syntax errors before running the example in your target interpreter.
Coroutines are not a universal replacement for tables or callbacks. They add a small per-resume cost and require discipline around cleanup. But when a producer and consumer are fighting over control flow, a pull-style generator often produces the flattest, most readable code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.