1. Stack trace: what it is and how to read it
When a beginner sees red text in the console, the first reaction is often: “Everything is broken, nothing makes sense, the computer hates me.” In reality, the red text is almost always a hint, and a fairly detailed one. And its most useful part is the stack trace: a chain of function calls that leads to the place where the error happened.
It’s important to accept a simple model: a program executes line by line, but often not “linearly” — it jumps between functions. When an exception is unhandled, the runtime prints a report: what type of error occurred and which functions we went through. Kotlin on the JVM prints a stack trace exactly like this: a line with the exception type, and then several lines of the form at ... (File.kt:line).
Stack trace as a “route” to the crash site
In plain human terms, a stack trace is a “route” the program took to reach the crash point. In Kotlin documentation, it’s explained as a report that shows the sequence of function calls up to the point where the error occurred. This is convenient: even if the error happened “deep” inside a helper function, you’ll see who called it, and who called the caller, and who called that caller… almost like a family tree, except instead of grandmas — functions.
On the JVM, a stack trace is usually printed automatically if nobody catches the exception. For example, if you simply throw an exception, you’ll see the type, the message, and lines at .... Let’s break down what one trace line consists of, so you don’t look at it like an ancient spell.
Anatomy of a stack trace line
| Example line | How to read it | Why it matters |
|---|---|---|
|
It crashed/was invoked in function main, file Main.kt, line 3 | This is almost always the “coordinates” of the problem |
|
Exception in thread "main" (in console tasks, that’s usually what you’ll see) | Mostly context; the key is the type and the coordinates |
|
Exception type and message | The type often explains the cause right away |
How to read a stack trace step by step
It’s very easy to drown in a stack trace if you try to read it like a novel. It’s not a novel. It’s closer to a GPS log: “where you were, where you turned, where you crashed into a fence.” So there’s a reading discipline: first determine what happened, then where it happened, and only then figure out why.
First, look at the exception type: NumberFormatException almost always means “someone tried to turn a string into a number, but the string was not a number.” ArrayIndexOutOfBoundsException almost always means “the index went out of array bounds.” Then look for the line with (...kt:NN) — that’s the line number in the file.
To cement this, let’s draw a very simple diagram of what happens with an unhandled exception:
flowchart TD
A["Executing main()"] --> B["Call function A()"]
B --> C["Call function B()"]
C --> D["Inside B() an exception occurs"]
D --> E{"Is there a catch in B()?"}
E -->|yes| F[Handle and continue]
E -->|no| G["Exception 'bubbles up' to A()"]
G --> H{"Is there a catch in A()?"}
H -->|no| I["Exception 'bubbles up' to main()"]
I --> J{"Is there a catch in main()?"}
J -->|no| K[Print stack trace and terminate the program]
Mini example: a call chain in a stack trace
Now we’ll make an example that intentionally “crashes” so we can look at the trace. It’ll be short, but important: we’ll see that the stack trace reflects a chain of functions, not just a single line.
fun level1() = level2()
fun level2() = level3()
fun level3() {
println(10 / 0) // ArithmeticException will happen here
}
fun main() {
level1()
}
What’s important to understand here: division by zero happens in level3, but we started everything from main, and between them there are level1 and level2. In the stack trace you’ll usually see that calls went top-down until they reached the problematic operation.
2. Practice: ExpenseBuddy and a crash on input
To make debugging feel like real life rather than a lab exercise about division by zero, let’s continue a small app we can grow throughout the course: ExpenseBuddy — a console expense tracker. So far without collections and classes (those come later), so we store data in a simple way: a fixed-size array plus a counter.
The idea is: we have an array Array<Pair<String, Int>?>, where the pair is (category, amount). The array is fixed, for example 5 entries, to avoid overcomplicating things. Our task today is to learn how to find exactly where the program crashed if the user entered something unexpected.
Version 0: adding an expense that can crash
fun readAmount(): Int {
print("Amount (integer): ")
return readln().trim().toInt() // may crash on "12a"
}
fun addExpense(expenses: Array<Pair<String, Int>?>, index: Int) {
print("Category: ")
val category = readln().trim()
val amount = readAmount()
expenses[index] = category to amount
println("Saved: ${expenses[index]}")
}
fun main() {
val expenses = arrayOfNulls<Pair<String, Int>>(5)
addExpense(expenses, 0)
}
If you enter the amount 12a, then toInt() will throw a NumberFormatException. And if we do not catch that exception, the JVM will print a stack trace.
The point is that the stack trace helps you answer two questions:
First — what happened: NumberFormatException (the string was not a number).
Second — where it happened: the line with readAmount() and the file/line reference. You’ll have something like ...readAmount(YourFile.kt:NN).
Why the trace is readable, but the cause still isn’t obvious
At this point a common situation happens: the stack trace shows where it crashed, but you look at the line and think: “Yes, there’s toInt(). But why is it not a number? I entered a number… I think…”.
And that’s where the second tool appears: the debugger. It’s useful when you need to see the actual values of variables right before the problematic line. For example: what’s really inside raw after readln(), how trim() behaved, whether an empty input came in, whether you substituted 0 somewhere via ?:, and so on.
You need a debugger not because you’re a “bad programmer”, but because humans are bad at simulating program execution in their heads. Especially when, at the same time, there’s a parallel thought spinning: “what should I eat?”
3. Debugger: basic techniques in IntelliJ IDEA
A debugger is a run mode where the program can stop on a chosen line, and you can see what’s going on inside: variable values, the current step, which function is executing. The most basic technique is a breakpoint (a stop point): you set it on a line, and when you start in Debug mode, execution will stop before executing that line.
Then you move forward in small steps. Usually you care about three buttons:
- Step Over — execute the current line without stepping into the called function.
- Step Into — step into the function that is called on the current line.
- (Sometimes) Step Out — exit the current function back to the line that called it.
At our level, the first two are enough: “don’t go in” and “go in”.
Debugging ExpenseBuddy: a breakpoint before the risky line
Let’s take readAmount() and make it a bit more “debug-friendly”: we’ll store the entered string in a variable so we have something to inspect in the Variables window.
fun readAmount(): Int {
print("Amount (integer): ")
val raw = readln()
val prepared = raw.trim()
return prepared.toInt() // breakpoint here
}
What to do in the IDE (in words, no magic):
Set a breakpoint on the line return prepared.toInt(). Run the program in Debug mode. Enter, for example, 12a. The program will stop at the breakpoint, and you’ll see the values:
- raw (for example "12a" or " 12a "),
- prepared (for example "12a").
After that, you can press Step Over — and see the program crash on that line. The key is: you have now seen with your own eyes that prepared is not a number.
Step Over vs Step Into: how not to “fly off” to the side
A very common beginner confusion: “I pressed some button and it took me somewhere else.” To avoid that, let’s try Step Into on a small example.
Let’s make a helper function:
fun parseAmountOrZero(text: String): Int {
return text.trim().toIntOrNull() ?: 0
}
fun main() {
print("Amount: ")
val raw = readln()
val amount = parseAmountOrZero(raw) // breakpoint here
println("amount = $amount") // amount = 0 (if you entered "12a")
}
If you set a breakpoint on the call line parseAmountOrZero(raw) and press Step Into, you’ll go inside parseAmountOrZero. This is useful when the result is unexpected (for example, you expected 12 but got 0). Step Into helps you see where exactly you “turned the wrong way”: trim() did something unexpected, toIntOrNull() returned null, the Elvis operator ?: substituted 0.
Where to look for the current place: Variables and Call Stack
The Variables window shows the values of variables at the current point. This is the first thing you should look at, because most beginner bugs are “the value is not what I thought.” But there’s one more thing, very similar to a stack trace: the Call Stack (call stack) right in the debugger.
And this nicely closes the loop: a stack trace is a “post-mortem report” when the program has already crashed and tells you which functions it went through. The Call Stack in the debugger is a “live stack”, right now, while the program is paused.
If you stopped inside readAmount(), the Call Stack will show that readAmount() was called from addExpense(), and addExpense() — from main(). This is almost the same as what you would see in the stack trace on a crash, just without the crash.
Kotlin nuance: weird names in the debugger
Sometimes when debugging Kotlin code, in Variables you may encounter variables with names like $i$f$... or similar “spy aliases”. This is not a compiler conspiracy against you (although it sounds plausible), but technical markers that the Kotlin compiler generates for debugging inline functions and lambdas, to build stepping and inline stack traces more correctly.
At our level, the takeaway is simple: if you see weird $i$f$... — don’t panic and don’t try to “fix the code”. Look at your own variables (raw, prepared, amount), and just ignore the service markers.
Which to choose: stack trace or debugger
A stack trace is good when the exception has already happened and you need to quickly understand “where and what”. It’s especially useful if you see the file and line, and the problem is obvious: division by zero, toInt() on a garbage string, an array index out of range.
A debugger is better when you roughly know “where”, but don’t understand “why exactly like this”. For example: where did 0 come from, why is the string empty, why did the index become 5, who changed the variable, why did the if branch trigger differently than you thought. There you need to look at variable values right before key actions.
4. Typical mistakes with stack traces and the debugger
Mistake #1: reading the stack trace bottom-up and looking for the “last line”.
Often there really is an at ... line at the bottom, but a beginner starts digging through the very last lines, while the most important info is usually closer to the beginning: the exception type and the first “your” frames with .kt files. It’s better to first find the type (NumberFormatException, etc.), then the first line that mentions your file and a line number.
Mistake #2: looking only at the exception message and ignoring the type.
The message (e.message) is sometimes useful, but the type is almost always more useful. “For input string: ...” is nice, but NumberFormatException immediately tells you the class of the problem: the string didn’t parse as a number. This saves time.
Mistake #3: setting a breakpoint after the risky line.
A breakpoint after prepared.toInt() is useless if toInt() crashes: execution will never reach it. The breakpoint must be before the suspicious operation so you can see variable values in time.
Mistake #4: using Step Into everywhere and getting lost in other people’s code.
If you press Step Into on toInt() or on println(), you risk “flying off” into library code (or Java code), and beginners get sad there. A good habit: Step Over by default, Step Into only when you want to enter your helper function.
Mistake #5: trying to debug without expectations.
The debugger shows facts, but to understand that a fact is strange, you need an expectation. Before pressing buttons, it’s useful to at least say to yourself: “I expect prepared to be "123".” If you see "12a" — that’s it, the cause is clear. Without expectations, you’ll just stare at numbers like they’re currency exchange rates.
Mistake #6: “fixing” crashes with a huge try/catch around the entire program.
That’s not debugging — it’s sweeping trash under a rug (and then tripping over the rug). The debugger and stack trace are needed precisely to find the point of the problem and fix the cause, not to pretend everything is fine.