Coroutines
Reviewed & published by Brayan K
By the end of this lesson you'll create and drive coroutines with create, resume, yield, status, and wrap, pass values in and out across yield/resume, build generator and producer-consumer patterns, understand why coroutines are cooperative rather than OS threads, and see why game loops love them.
Part of the free Lua course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
- Create coroutines and drive them with resume and yield
- Read a coroutine's status: suspended, running, normal, dead
- Pass values into a coroutine and get values back out
- Use coroutine.wrap to make clean generators and iterators
- Build producer-consumer pipelines with cooperative scheduling
- Understand coroutines vs OS threads and their use in game loops
💡 Real-World Analogy
A coroutine is like reading a book with a bookmark. You read for a while, then slip the bookmark in and put the book down to do something else (that's yield). Later you pick it up and carry on from the exact page you left (that's resume) — no need to start over. Unlike juggling several books at once on different tables (true parallel threads), you only ever read one book at a time, and you choose precisely when to set it down. That cooperative, "pause exactly here and continue exactly there" behaviour is the whole idea.
1. Create, Resume, Yield & Status
A coroutine is a function that can pause itself with coroutine.yield and later continue from that exact point. You build one with coroutine.create(fn), which returns a coroutine that starts suspended. Each coroutine.resume(co) runs it until the next yield, the end of the function, or an error. coroutine.status(co) tells you where it stands — suspended, running, normal, or dead — and a finished (dead) coroutine can't be resumed again. Read and run this example.
-- A coroutine is a function that can PAUSE itself and later RESUME
-- from exactly where it paused. Only one runs at a time (cooperative).
-- create() makes a coroutine; it starts SUSPENDED, not running.
local co = coroutine.create(function()
print("step 1")
coroutine.yield() -- pause here, hand control back
print("step 2")
coroutine.yield() -- pause again
print("step 3") -- runs on the third resume, then finishes
end)
print(coroutine.status(co)) -- prints: suspended
coroutine.resume(co) -- prints: step 1 (runs to first yield)
print(coroutine.status(co)) -- prints: suspended
coroutine.resume(co) -- prints: step 2
coroutine.resume(co) -- prints: step 3 (reaches the end)
print(coroutine.status(co)) -- prints: dead (finished, can't resume)
-- Resuming a dead coroutine returns false, not an error:
print(coroutine.resume(co)) -- prints: false cannot resume dead coroutine2. Passing Values In and Out
Coroutines are a two-way channel. On the very first resume, the extra arguments become the body function's parameters. After that, whatever you pass to resume becomes the return value of the yield that paused it. Going the other way, the values you hand to yield come back as the extra results of resume (after the leading true success flag), and a final return is delivered just like one last yield. This flow is what powers generators and pipelines.
-- Values flow BOTH ways across yield/resume.
-- resume returns: true (ok) , then whatever yield handed out.
-- yield returns: whatever the NEXT resume passes back in.
local co = coroutine.create(function(a, b)
print("got args", a, b) -- a, b come from the FIRST resume
local x = coroutine.yield(a + b) -- send out a+b, pause; x = next resume's value
print("resumed with", x)
return "done" -- a final return is delivered like a yield
end)
-- First resume: its extra args become the function's parameters.
local ok, out = coroutine.resume(co, 10, 5)
print(ok, out) -- prints: true 15 (15 is the yielded a+b)
-- Second resume: 99 becomes the RESULT of the yield (the x above).
local ok2, out2 = coroutine.resume(co, 99)
print(ok2, out2) -- prints: true done ("done" was returned)Your turn. Drive a simple coroutine through its single pause and confirm it ends up dead. Fill in the blanks marked ___.
-- 🎯 YOUR TURN — drive a coroutine through its yields.
local co = coroutine.create(function()
print("a")
coroutine.___() -- 👉 the function that PAUSES the coroutine
print("b")
end)
-- 1) Run it up to the first pause
coroutine.resume(___) -- 👉 the coroutine to run
-- 2) Run it again to finish
coroutine.resume(co)
-- 3) Check it is finished
print(coroutine.status(co))
-- ✅ Expected output:
-- a
-- b
-- dead3. wrap, Generators & Producer-Consumer
coroutine.wrap turns a coroutine into a plain function: each call resumes it and returns the yielded values directly, with no true flag to unpack (errors are re-raised instead of returned). That makes wrap perfect for generators — functions that produce a stream of values on demand — and for producer-consumer setups where one coroutine yields items and a loop consumes them. A wrap result even works directly as a for-loop iterator.
-- coroutine.wrap turns a coroutine into a plain FUNCTION.
-- Each call resumes it and returns the yielded values directly.
-- (Errors are raised instead of returned as false.)
-- A GENERATOR that produces an endless stream of squares:
local function squares()
return coroutine.wrap(function()
local n = 1
while true do
coroutine.yield(n * n) -- hand out the next square, then pause
n = n + 1
end
end)
end
local nextSquare = squares()
print(nextSquare()) -- prints: 1
print(nextSquare()) -- prints: 4
print(nextSquare()) -- prints: 9
-- PRODUCER/CONSUMER: a producer yields items, a consumer pulls them.
local producer = coroutine.wrap(function()
for _, fruit in ipairs({ "apple", "pear", "plum" }) do
coroutine.yield(fruit)
end
end)
for fruit in producer do -- wrap result works as a for-iterator
print("consumed", fruit)
end
-- prints: consumed apple / consumed pear / consumed plumNow you try a generator. Finish the counter so each call hands out the next number starting from start. Fill in the blank:
-- 🎯 YOUR TURN — build a counter generator with wrap.
local function counter(start)
return coroutine.wrap(function()
local n = start
while true do
coroutine.yield(___) -- 👉 hand out the current value n
n = n + 1
end
end)
end
local nextNum = counter(10)
print(nextNum()) -- 👉 should print the start value
print(nextNum())
print(nextNum())
-- ✅ Expected output:
-- 10
-- 11
-- 12Pro Tips
- 💡 Use wrap for generators, create for control. wrap is tidier; create lets you check status and catch errors via the success flag.
- 💡 Always check resume's first return. With create, a false there means the coroutine errored — the second value is the message.
- 💡 One must yield. Coroutines are cooperative; a coroutine that loops without yielding blocks everything else.
- 💡 Coroutines are not parallelism. For real multicore work you need actual threads or processes — coroutines just interleave on one core.
Common Errors (and the fix)
- "cannot resume dead coroutine": you resumed one that already finished. Fix: check coroutine.status(co) == "suspended" before resuming, or create a fresh coroutine.
- "attempt to yield from outside a coroutine": you called coroutine.yield in the main chunk. Fix: yield only inside a function running under resume or wrap.
- Errors vanish with create: an error inside the coroutine makes resume return false not throw. Fix: capture both returns, local ok, err = coroutine.resume(co), and check ok.
- Expecting parallel speed-up: two coroutines do not run at once. Fix: for true parallelism use OS threads or separate processes; coroutines only interleave cooperatively.
📋 Quick Reference
| Task | Code | Result / Note |
|---|---|---|
| Create | coroutine.create(fn) | starts suspended |
| Resume | coroutine.resume(co, ...) | true, yielded... |
| Yield | coroutine.yield(...) | pause, send out |
| Status | coroutine.status(co) | suspended/dead/... |
| Wrap | coroutine.wrap(fn) | callable generator |
| Who am I | coroutine.running() | current coroutine |
Mini-Challenge: a frame-by-frame behaviour
No blanks this time — just a brief and an outline. You'll write a coroutine that does one step per "frame" and yields between them, exactly how scripted behaviours work in real game engines. Build it, run it, and check your output.
-- 🎯 MINI-CHALLENGE: a frame-by-frame game behaviour
-- 1. Write walkTo(steps) that returns coroutine.wrap of a function which,
-- for i = 1, steps, prints "step " .. i and then coroutine.yield()s
-- (simulating doing one step per frame).
-- 2. Drive it like a game loop: call the wrapped function once per "frame"
-- until coroutine.status would be dead. With wrap you can simply loop a
-- fixed number of times, or check a returned flag.
--
-- ✅ Example shape:
-- local move = walkTo(3)
-- move() -- step 1 (frame 1)
-- move() -- step 2 (frame 2)
-- move() -- step 3 (frame 3)
-- your code here🎉 Lesson Complete!
- ✅ create makes a suspended coroutine; resume runs it to the next yield
- ✅ yield pauses and sends values out; the next resume sends values back in
- ✅ status reports suspended, running, normal, or dead
- ✅ wrap gives a plain callable, ideal for generators and for-loop iterators
- ✅ Coroutines are cooperative and single-threaded — not OS threads or true parallelism
Practice quiz
Which function creates a new coroutine from a function?
- coroutine.create
- coroutine.new
- coroutine.spawn
- coroutine.make
Answer: coroutine.create. coroutine.create(fn) returns a coroutine object that is initially suspended.
How do you start or continue a suspended coroutine?
- coroutine.start
- coroutine.run
- coroutine.resume
- coroutine.go
Answer: coroutine.resume. coroutine.resume(co, ...) runs the coroutine until it yields, finishes, or errors.
What does coroutine.yield do?
- Ends the coroutine permanently
- Suspends the coroutine and returns control to the resumer
- Creates a new thread
- Pauses the whole program for a second
Answer: Suspends the coroutine and returns control to the resumer. yield suspends the coroutine, handing values and control back to whoever resumed it.
Are Lua coroutines preemptive OS threads?
- Yes, they run in parallel on multiple cores
- Only on Linux
- Only in Lua 5.4
- No, they are cooperative and run one at a time
Answer: No, they are cooperative and run one at a time. Coroutines are cooperative: exactly one runs at a time and it must yield to give up control.
What does the first return value of coroutine.resume indicate?
- A boolean success flag
- The coroutine's status string
- The number of yields so far
- The coroutine's id
Answer: A boolean success flag. resume returns true plus yielded values on success, or false plus an error message on failure.
Which function returns a coroutine's status such as 'suspended' or 'dead'?
- coroutine.info
- coroutine.status
- coroutine.check
- coroutine.state
Answer: coroutine.status. coroutine.status(co) reports suspended, running, normal, or dead.
What does coroutine.wrap return?
- A status string
- A coroutine object
- A table of yielded values
- A function you call to resume the coroutine
Answer: A function you call to resume the coroutine. wrap returns a callable; each call resumes the coroutine and returns its yielded values directly, raising errors instead of returning false.
How are values passed INTO a coroutine on the very first resume?
- They become the return of yield
- You cannot pass values in
- As the arguments to the coroutine's body function
- Through a global variable
Answer: As the arguments to the coroutine's body function. On the first resume, the extra arguments become the parameters of the function the coroutine wraps.
After the first resume, where do values passed to resume appear?
- As new function arguments
- As the return values of the yield that paused it
- In coroutine.running
- They are discarded
Answer: As the return values of the yield that paused it. On later resumes, the values you pass become the results returned by the yield that suspended the coroutine.
A common use of coroutines in game loops is to...
- Write step-by-step behaviours that pause and resume each frame
- Run code on multiple CPU cores at once
- Replace the table data structure
- Speed up math operations
Answer: Write step-by-step behaviours that pause and resume each frame. Coroutines let you write scripted, frame-by-frame behaviours that yield each frame and resume on the next.
Continue this course
- Previous: OOP with Metatables
- Next: Modules & require — Split code into files that return tables and load them with require
Frequently asked questions
How are coroutines different from OS threads?
OS threads are preemptive and can run in parallel on multiple CPU cores; the operating system switches between them at any moment, so you need locks to avoid data races. Lua coroutines are cooperative and single-threaded: exactly one coroutine runs at a time, and it only gives up control when it explicitly calls coroutine.yield. That makes them far easier to reason about — there are no surprise context switches and no locking — but it also means a coroutine that never yields will block everything, and coroutines alone do not give you true multicore parallelism.
What is the difference between coroutine.create and coroutine.wrap?
create returns a coroutine object that you drive with coroutine.resume, and resume returns a success boolean first so you can detect errors yourself. wrap returns a plain function: each call resumes the coroutine and returns the yielded values directly, with no success boolean. The trade-off is error handling — wrap re-raises any error inside the coroutine to the caller, whereas resume catches it and hands it back as false plus a message. Use wrap for clean generators and iterators; use create when you need to inspect status or handle errors gracefully.
How do values pass into and out of a coroutine?
Values flow in both directions across the yield/resume boundary. On the very first resume, the extra arguments you pass become the parameters of the coroutine's body function. After that, any values you pass to resume become the return values of the yield that paused the coroutine. Going the other way, the values you pass to yield become the extra return values of resume, and a final return from the body is delivered just like a last yield. This two-way channel is what makes producer/consumer patterns natural.
What does coroutine.status report and when is it 'normal'?
status returns one of four strings. 'suspended' means the coroutine is paused and can be resumed. 'running' is what it sees about itself while executing. 'dead' means it has finished or errored and can no longer be resumed. 'normal' is the less common one: it describes a coroutine that has resumed another coroutine and is waiting for that inner one to finish — so it is active but not the one currently running. coroutine.running() tells you which coroutine is executing right now.
Why are coroutines so useful in game loops?
A game runs a loop that updates everything once per frame, and many behaviours are naturally step-by-step over time: walk to a point, wait two seconds, then open a door. Without coroutines you would track that progress with state machines and counters. With a coroutine you write the behaviour as ordinary sequential code that calls yield each frame; the engine resumes it next frame and it continues exactly where it left off. This turns awkward state tracking into readable scripts, which is why engines like LOVE and Roblox lean on them.