A user opened our mobile app and saw this on their profile screen:
Membership: null
Points: null
Last payment: null
The same account on the website showed empty fields and a zero. No crash, no error dialog, no red line in the log. The app had received the data, parsed it successfully, and printed the four characters n, u, l, l into a TextView.
The parsing code looked like it had already handled this:
val plan = obj.optString("membership", "")
That is the whole bug. On Android, when membership is JSON null, optString does not return "". It returns the string "null".
Why it does that
org.json on Android represents JSON null with a sentinel object, JSONObject.NULL. It is not Java null — it is a real object that exists specifically so the library can tell "the key is absent" apart from "the key is present and its value is null."
Android's implementation of optString is, in essence:
public String optString(String name, String fallback) {
Object object = opt(name);
String result = JSON.toString(object); // ~ object.toString()
return result != null ? result : fallback;
}
opt("membership") returns the sentinel. The sentinel is not Java null, so the fallback branch is never reached. toString() is called on it, and JSONObject.NULL.toString() returns — by design, because it has to serialize back to valid JSON — the text "null".
The fallback works exactly as documented for a missing key. It does nothing for a null key. Those are two different states and the API gives them one parameter.
getString has the same outcome. It does not throw on a null value; it hands you "null" as well.
Now compare the neighbours:
obj.optInt("points", 0) // JSON null -> 0 ✅
obj.optBoolean("active", false) // JSON null -> false ✅
obj.optString("plan", "") // JSON null -> "null" ❌
optInt and optBoolean route through converters that return Java null for the sentinel, so the fallback fires. Only the string path stringifies the sentinel. This inconsistency is why the bug survives review: three lines that look identical, and one of them behaves differently.
It also depends on which org.json you are compiled against
This is worth checking rather than assuming. The reference implementation from json.org — the org.json:json jar you would pull into a plain JVM or server-side project — has a version of the same method that reads roughly:
public String optString(String key, String defaultValue) {
Object object = this.opt(key);
return NULL.equals(object) ? defaultValue : object.toString();
}
It checks the sentinel explicitly and returns your default. Same package, same class, same method signature, different answer.
Android ships its own Harmony-derived implementation in the framework, and it wins on the classpath. So a unit test that runs on the JVM with the json.org jar can pass while the device fails. If your test source set is green and your androidTest source set is red on the same assertion, this is a candidate.
Confirm it in ten seconds with an instrumented test rather than trusting either the docs or this article:
@Test fun optStringOnJsonNull() {
val o = JSONObject("""{"a": null}""")
assertEquals("", o.optString("a", "")) // fails on device
}
Where the null came from in the first place
Fixing the client is only half of it, because the response should not have contained nulls for those fields at all.
The API was PHP, and the payload was built from a query that looked something like:
SELECT
m.plan_name,
SUM(p.amount) AS total_paid,
MAX(p.paid_at) AS last_payment
FROM members m
LEFT JOIN payments p ON p.member_id = m.id
WHERE m.id = ?
GROUP BY m.id
Two separate null factories in one query:
-
LEFT JOINwith no matching rows. Every column frompaymentscomes backNULL. -
Aggregates over an empty set.
SUM()andMAX()returnNULL, not0and not'', when there are no rows to aggregate.COUNT()is the exception — it returns0— which is exactly why people assume the others do too.
The website never showed this because the view layer cast on the way out:
echo (int)($row['total_paid'] ?? 0);
echo htmlspecialchars($row['plan_name'] ?? '');
The ?? 0 there is doing real work — ?? catches SQL NULL once PDO has turned it into PHP null. Web output was clean. The JSON endpoint serialized the same row directly, json_encode mapped PHP null to JSON null, and the app printed the word.
This is the general shape: a null that is invisible in one rendering path becomes visible in another. Two consumers of the same data, one of which happens to have a defensive cast in its template.
Why the test accounts never caught it
The seeded test users all had payment history. Every demo account had rows. The only way to produce this response was to log in as a member with nothing — a brand new registration, or an account whose records had been archived.
A user with zero rows is a boundary condition, and it is the one that seed scripts systematically do not create, because seed scripts exist to make screens look populated. If you write fixtures, make one account deliberately empty and put it in the smoke test.
Fix both layers, not one
The tempting move is to fix the server, since that is where the null originates, and declare the client fine. That is wrong for mobile specifically, and here is why.
Old app versions live forever. You control your server. You do not control which build is on a user's phone. A client that renders "null" when handed a JSON null is a permanently loaded gun pointed at every future endpoint, including ones written by someone who has never heard of this bug.
Caches outlive deploys. An HTTP cache, a CDN, or a local response cache can hand the app a pre-fix payload well after the server is patched.
New fields arrive constantly. Server-side normalization is a policy you have to remember to apply. Client-side hardening is a helper you cannot forget to use, because it is the only way to read a string.
So: both.
Server side — normalize once, at the boundary
Recursively convert nulls at the point where the response is serialized, not in each handler:
function json_clean(mixed $value): mixed
{
if (is_array($value)) {
foreach ($value as $k => $v) {
$value[$k] = json_clean($v);
}
return $value;
}
return $value ?? '';
}
// single exit point for every API response
function api_respond(array $payload, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(json_clean($payload), JSON_UNESCAPED_UNICODE);
}
One caveat worth stating plainly: this flattens the difference between "no value" and "empty value". For a display API that is what you want. For an API where a client needs to distinguish "field not provided" from "field explicitly cleared" — a PATCH-style contract, for instance — do not do this globally. Fix those fields at the query instead:
COALESCE(SUM(p.amount), 0) AS total_paid,
COALESCE(m.plan_name, '') AS plan_name
COALESCE is the honest fix; the recursive normalizer is the safety net under it.
Client side — never call optString directly again
Wrap it once, ban the raw call in review:
object Json {
fun text(obj: JSONObject?, key: String, fallback: String = ""): String {
if (obj == null || !obj.has(key) || obj.isNull(key)) return fallback
val value = obj.optString(key, fallback).trim()
// a server can also send the *string* "null" — from string concatenation,
// a logging wrapper, or an older client that echoed this bug back
if (value.isEmpty() || value == "null" || value == "undefined") return fallback
return value
}
fun number(obj: JSONObject?, key: String, fallback: Long = 0): Long {
if (obj == null || obj.isNull(key)) return fallback
return obj.optLong(key, fallback)
}
fun textOrNull(obj: JSONObject?, key: String): String? =
text(obj, key, "").ifEmpty { null }
}
isNull(key) is the load-bearing line. It returns true for both Java null and the JSONObject.NULL sentinel, which is the check optString fails to make on your behalf.
Filtering the literal "null" string looks paranoid until you have seen a server do "Welcome, " + name in a template and store the result. Once a "null" has been written to a database by an upstream system, no amount of correctness downstream removes it — you have to strip it at read time.
Then make the raw call unusable:
// build.gradle.kts — fail the build, not the user
// (or use Detekt's ForbiddenMethodCall rule)
A lint rule beats a code review comment, because the review comment only catches the calls written while the reviewer remembers the bug.
What about Gson, Moshi, kotlinx.serialization?
Mostly they handle this correctly, with one trap of their own.
Moshi and kotlinx.serialization are aware of Kotlin nullability. Declare val plan: String as non-null and feed it JSON null, and they throw at parse time — a loud, immediate, correctly-located failure. That is the behaviour you want.
Gson does not. It constructs objects through unsafe reflection and bypasses Kotlin's null checks entirely, so a JSON null lands in a field the type system swears is non-null. Nothing fails at parse time. The NPE happens later, somewhere unrelated, and the stack trace points at the innocent code that touched the field rather than the parser that lied about it. That is arguably worse than "null" on screen, because at least "null" on screen tells you where it came from.
If you are on Gson, declare the fields nullable (String?) and handle the nulls explicitly. Fighting for non-null fields you cannot enforce buys nothing.
org.json remains everywhere despite all three of these, for a simple reason: it is in the Android framework, weighs nothing, and needs no annotation processor. Small apps and old modules keep using it. It works fine — as long as you know that its string path does not have a null story.
The five-minute audit
Worth running on any app that talks to an API you also control:
-
grep -rn "optString(" app/src— every hit is a candidate. Replace with a helper that callsisNullfirst. -
grep -rn "getString(" app/srconJSONObjectreceivers — same problem, no fallback parameter to give false comfort. - In the API, find aggregates without
COALESCE:SUM(,MAX(,MIN(,AVG(— and everyLEFT JOINselecting columns from the right-hand table. - Create one test account with zero related rows and open every screen with it. This finds more null bugs in ten minutes than reading code for a day.
-
grepyour database for the literal string'null'in text columns. If you find any, something upstream has already been concatenating nulls into text, and that data needs cleaning independently of any code fix.
The underlying lesson is not about one library method. It is that null crosses system boundaries badly. SQL NULL, PHP null, JSON null, Java null, Kotlin null?, and a sentinel object that stringifies to "null" are six different things wearing the same name, and every hop between two of them is a place where one quietly becomes another. Put the conversion in one function per boundary, and you only have to be right once.
I build and run news, e-commerce and membership platforms at alestaweb.com. This one took an afternoon and a screenshot from a user who assumed "null" was our membership tier.
Top comments (0)