1. Introduction
When you first run into null in data, it feels like: “Well, null is null; I know how to write ?:.” But sorting is not just “handle each element individually.” Sorting requires comparing elements pairwise, which means you need an answer to the question: if one element is null and the other is not null, which one is “greater” and which is “smaller”? By meaning, null has no natural place in an ordering, so this decision is always a bit “political”: you agree upfront where null should end up — at the beginning or at the end.
In Kotlin, sorting is built around comparability and comparators: either natural order (Comparable) or a custom order via Comparator (including the convenient compareBy). If your sorting key can be null, you must make the rule explicit. Yes, sometimes the standard library can “somehow” compare nullable keys, but the problem is not only the compiler — it’s readability. A week later, you won’t remember why null ended up at the start of the list rather than at the end.
Let’s tie this to our practical console app (let’s call it BudgetBuddy): we store expenses as pairs "category" to amount. And let’s assume the amount is sometimes unknown (null) — for example, you decided to allow “draft” expenses: the category is entered now, and the amount later.
2. Data model: expenses with an unknown amount
When we discuss null, it’s important not to turn it into abstract philosophy. Let null mean something specific. In our app, we’ll treat null as an unknown amount. It’s not “zero”; it’s “no data.” And that matters: zero is a number, while null is the absence of a number.
Here’s a small sample dataset that’s convenient for practice:
fun main() {
val expenses: List<Pair<String, Int?>> = listOf(
"food" to 250,
"taxi" to null,
"books" to 90,
"food" to null
)
println(expenses)
// [(food, 250), (taxi, null), (books, 90), (food, null)]
}
Here you can see that null is not some exotic exception; it’s a normal participant in the collection. By definition, Kotlin’s nullable types force us to explicitly acknowledge that a value may be missing, and that’s good: the language literally won’t let you “forget” about the hole in the data.
Now imagine we want to build a report: print categories sorted by amount (for example, so we can later take top‑N). But some amounts are null. Where do we put them?
3. Strategy 1: a default via ?:
The most popular approach among beginners is: “let’s substitute something instead of null and keep going.” This is a valid strategy if you carefully choose the replacement. The Elvis operator ?: is designed for exactly this: “if the left side is null, take the right side.”
null at the end when sorting ascending
If we sort ascending and want unknown values to go to the end, the logic is simple: for null, substitute a very large number so it becomes “the very last.”
fun main() {
val totals: List<Pair<String, Int?>> = listOf(
"food" to 250,
"taxi" to null,
"books" to 90
)
val sorted = totals.sortedBy { (_, total) ->
total ?: Int.MAX_VALUE
}
println(sorted)
// [(books, 90), (food, 250), (taxi, null)]
}
Here the idea is readable even to someone who doesn’t know Kotlin: “if there is no amount, treat it as infinitely large so it flies to the end.”
The trick is simple, but there’s a nuance: you must not pick the default “at random.” If you use 0, null will end up at the beginning, and it will look like “taxi = 0,” even though we meant “taxi = unknown.”
null at the beginning when sorting ascending
Sometimes, semantically, you want to see all “holes” at the top so the report starts with problematic data. Then for null you substitute a very small number.
fun main() {
val totals: List<Pair<String, Int?>> = listOf(
"food" to 250,
"taxi" to null,
"books" to 90
)
val sorted = totals.sortedBy { (_, total) ->
total ?: Int.MIN_VALUE
}
println(sorted)
// [(taxi, null), (books, 90), (food, 250)]
}
This is a different agreement: “null is the smallest.” From an algorithm perspective it’s consistent. From a business perspective it’s also consistent if that’s what you decided.
sortedByDescending and the “reversed” default
This is where beginners often stumble: in sortedByDescending, everything is reversed. If you want null at the end when sorting descending, then null must become very small, otherwise it suddenly ends up at the beginning as “the biggest.”
fun main() {
val totals: List<Pair<String, Int?>> = listOf(
"food" to 250,
"taxi" to null,
"books" to 90
)
val sorted = totals.sortedByDescending { (_, total) ->
total ?: Int.MIN_VALUE
}
println(sorted)
// [(food, 250), (books, 90), (taxi, null)]
}
If your amounts cannot be negative by definition, you can choose an even more “speaking” default, such as -1, to emphasize: “invalid/unknown.”
4. When ?: becomes dangerous
The “substitute a number” strategy looks like a universal key, but it has a hidden cost. If you substitute a default, you effectively embed a business decision into arithmetic: you turn “no data” into “some number.” Yes, you do it only for sorting, but the next reader of the code may not understand that it’s a “technical hack” rather than real domain logic.
Imagine you substituted Int.MAX_VALUE, and then someone decided to compute an “average” over those keys (or add another pipeline step) and accidentally used the already “replaced” numbers. Suddenly the report will show a category with amount 2147483647, and it will look like a serious financial issue, while in reality it’s a programmer issue.
So the rule is: the ?: strategy is good when you use it only as the sort key (inside sortedBy({ … })), and keep the original data (null) as-is. That is, we don’t rewrite the data; we only choose how to compare it.
5. Strategy 2: separate null and non-null
The second strategy is usually longer, but it wins on readability. Instead of “pretending that null is a number,” we say honestly: “first sort the known values, then append the unknown ones where we agreed.”
This approach is especially good when null means “unknown,” and you don’t want anyone to even theoretically mistake it for “0” or “infinity.”
Let’s start with a simple case: a list of numbers List<Int?>.
fun main() {
val xs: List<Int?> = listOf(3, null, 1, null, 2)
val sortedNotNull = xs.filterNotNull().sorted()
val nullCount = xs.count { it == null }
val result = mutableListOf<Int?>()
for (v in sortedNotNull) result.add(v)
repeat(nullCount) { result.add(null) }
println(result)
// [1, 2, 3, null, null]
}
The code is more verbose, but it’s extremely straightforward. We used filterNotNull() and count { … }, which you have already seen in collection operations: first we “cleaned” the data, then counted how many we removed, and finally returned null back — but now at the end of the list. And yes, this technique explicitly emphasizes that null did not get lost; it was temporarily moved into a “separate pocket.”
6. Choosing a strategy and a readable rule for null
Sorting pairs while keeping the category
Real reports rarely sort “just numbers.” Usually we have a “name–value” link, and it’s important not to lose the name. So let’s apply the split strategy to List<Pair<String, Int?>>.
The idea of “split into two collections” is convenient here: one with known totals, the other with unknown totals. Kotlin has partition, but even without it you can write a transparent loop (and loops are usually clearer to beginners than “magic” functions).
fun main() {
val totals: List<Pair<String, Int?>> = listOf(
"food" to 250,
"taxi" to null,
"books" to 90
)
val known = mutableListOf<Pair<String, Int>>()
val unknown = mutableListOf<Pair<String, Int?>>()
for ((cat, total) in totals) {
if (total == null) unknown.add(cat to null) else known.add(cat to total)
}
val knownSorted = known.sortedBy { it.second }
val result = knownSorted.map { it.first to it.second } + unknown
println(result)
// [(books, 90), (food, 250), (taxi, null)]
}
Notice the trick: in known we store already non-null totals (Int), so later sorting by it.second becomes “normal,” without ?:. This reduces the risk of accidentally treating null as a number and makes the rest of the logic simpler.
A mini guideline: when to use ?: vs splitting
When you get to choose between two approaches, it’s easy to go to extremes. Some people start writing ?: Int.MAX_VALUE everywhere and feel like wizards. Others, on the contrary, split data into five lists everywhere and feel like accounting. To avoid overdoing it, it helps to have a simple criterion: how dangerous it is to “replace meaning,” and how important readability is.
Below is a small guideline. It’s not a law, but it helps you make a decision without long meditation.
| Situation | ?: (default) | Splitting (split + merge) |
|---|---|---|
| Data is small, rule is simple (“null to the end”) | Usually great | Also OK, but can be verbose |
| The default is easy to choose so it is definitely outside the domain | Great | Not required |
| There is a risk the default overlaps with real values | Dangerous and non-obvious | More reliable |
| You’re afraid someone will “reuse” the default as a real number | Better not | More reliable |
| You need to explicitly preserve/show null as a data issue | May hide meaning | Emphasizes meaning |
And if you want a very short rule: if you can explain at a glance why the default is safe, use ?:. If you start saying “well, in general our amounts are almost always positive, except…,” it’s better to split.
“Ready-made rules”: null first/last as an idea
Sometimes you’ll see code where null is placed “at the beginning/at the end” via ready-made comparators or special comparison functions. Today we won’t dive into the “zoo” of comparators (so the topic doesn’t balloon), but it’s important not to be scared: it’s the same idea, just packaged into a library tool.
In any case, the core idea is unchanged: sorting is a contract, and for null you must choose an explicit place. Kotlin in general likes when intent is readable in code: like with nullable types, where T? literally says “the value may be missing.” And for sorting, Kotlin provides tools like sortedWith(compareBy{ … }) so the comparison rule is explicit rather than hidden inside a mysterious if.
7. Embedding into the app: a category report
Now let’s make a small piece of “what this looks like in real app code,” without complicating the architecture. Suppose we have a function that builds “totals by category,” but if at least one expense in a category is unknown (null), then the total is also unknown (because we can’t honestly compute the sum).
fun totalsByCategory(expenses: List<Pair<String, Int?>>): List<Pair<String, Int?>> {
val categories = expenses.map { it.first }.distinct()
val result = mutableListOf<Pair<String, Int?>>()
for (cat in categories) {
var total = 0
var hasUnknown = false
for ((c, amount) in expenses) {
if (c != cat) continue
if (amount == null) hasUnknown = true else total += amount
}
result.add(cat to if (hasUnknown) null else total)
}
return result
}
Now we’ll apply sorting so that null is at the end, and known totals are ordered descending (a typical “top categories,” just without take(n) — you did that in the previous lecture).
fun main() {
val expenses = listOf(
"food" to 250,
"taxi" to null,
"books" to 90,
"food" to null
)
val totals = totalsByCategory(expenses)
val sorted = totals.sortedByDescending { (_, total) ->
total ?: Int.MIN_VALUE
}
println(sorted)
// [(books, 90), (food, null), (taxi, null)]
}
Notice: food and taxi are both null, so their relative order is not that important anymore (it may depend on the original order). The main point is they both honestly end up at the end. And books with a known total is at the front.
And yes, if Int.MIN_VALUE as a “magic number” bothers you, that’s a normal reaction. In that case, it’s exactly where you should prefer the splitting strategy: it’s longer, but less magical.
8. Common mistakes
Mistake #1: substituting a default “for convenience” without thinking about the meaning of the data.
The most common variant is total ?: 0. That is almost always untrue: 0 is “definitely zero,” while null is “unknown.” After such a substitution, the report becomes logically incorrect: categories with unknown totals suddenly look “smallest,” and you end up feeding yourself the wrong conclusions.
Mistake #2: mixing up defaults for sortedBy and sortedByDescending.
In descending sort, many people automatically do ?: Int.MAX_VALUE “so it ends up at the end,” but they get null at the beginning, because “maximum” in descending order is the leader. A good way not to mess this up is to say it out loud: “I want null to be worse than everyone; therefore, in descending it must become the smallest.”
Mistake #3: accidentally losing null when it is meaningful.
The function filterNotNull() is great, but it literally means “throw away all null.” If null is important as a marker of a data issue, then after filterNotNull() you won’t be able to tell the user “you have 2 records without an amount.” In such cases, either count null separately (count { it == null }), or use splitting rather than full removal.
Mistake #4: making the sort key too “smart,” so it becomes impossible to read.
Sometimes you see a lambda in sortedWith or sortedBy that normalizes, validates, calls trim(), handles null, and also adds something “to make it look nicer.” As a result, sorting turns into a mini-program inside the program. If the rule doesn’t fit into one clear line, it’s better to extract it into a small function with a meaningful name — and sorting becomes readable again.
Mistake #5: using “magic numbers” without a comment and without a connection to the domain.
Int.MIN_VALUE and Int.MAX_VALUE technically work, but to a beginner they look like a spell. If you still choose the ?: strategy, try either to pick a domain default (for example, -1 if amounts cannot be negative), or at least leave a short comment explaining why that specific value is safe.
GO TO FULL VERSION