Seeing Gson null fields only in the release build? If the model is a Kotlin data class, there are usually two problems stacked together. The first is R8. Gson finds fields by reflection. R8 renames or strips anything no keep rule protects. Since AGP 8.0, R8 runs in full mode by default. Full mode can also remove the class’s constructor and mark the class abstract. The second problem exists in debug too. That’s what makes the first one confusing. When a Kotlin class has no no-args constructor, Gson never calls your constructor at all. It allocates the object through JDK Unsafe. Kotlin’s null checks and default values never run. A property typed String can end up holding null. Parsing succeeds. The crash lands later, wherever the field is first read. Adding @SerializedName fixes the first bug but not the second. The fix has to cover both.
A
UserApireturns the signed-in user from/methrough Retrofit’s Gson converter. Everything works on the emulator. QA installs the release build. The profile screen crashes with aNullPointerExceptiononuser.name.length. Butnameis declared as a non-nullString. The JSON on the wire is fine. What’s wrong?
data class User(
val id: Long,
val name: String,
val role: String = "member",
)
interface UserApi {
@GET("me")
suspend fun me(): User
}
// Retrofit.Builder()
// .baseUrl(BASE_URL)
// .addConverterFactory(GsonConverterFactory.create())
// .build()
The response body is {"id": 42, "name": "Arsam"}. There’s no role key. That’s why the class gives it a default.
Gson null fields start in the debug build
Start with debug, where R8 doesn’t run. Gson still skips the constructor, for reasons covered below. So role comes back null, not "member". That’s despite it being a non-null String with a default. We ran this model through Gson 2.11.0 on the JVM and got User(id=42, name=Arsam, role=null). Nobody notices because the UI never reads role on that screen. That’s the second bug, sitting in debug the whole time. Release is where the first bug arrives and turns it into a crash.
Bug 1: R8 can’t see reflection, so it renames or deletes what Gson needs
Gson maps JSON keys to Java field names at runtime. R8 works at build time. Nothing in your code reads User.name by that literal name. So R8 treats the name as free to change. Gson’s troubleshooting guide describes the symptom. The app works in debug but fails in release. The fields end up with short random names like a and b. Once name becomes b, no JSON key matches it. Gson leaves every field at its JVM default. That’s 0 for the Long and null for both strings. R8’s compatibility mode stops here.
Full mode goes further. The Android developer docs confirm it. “R8 full mode has been the default since Android Gradle Plugin (AGP) 8.0.” The R8 compatibility FAQ lists what changes. Keeping a class no longer keeps its default constructor. Classes created only through reflection need their own explicit keep rule. Only Gson ever creates User. So from R8’s point of view, no code instantiates it. Gson’s guide names the result. R8 can remove the no-args constructor and make the class abstract. Gson then refuses to build an abstract class. It throws a JsonIOException that starts “Abstract classes can’t be instantiated!”
That loud failure is the better outcome. The quiet one is where most people end up after their first search. They add -keep class User and the crash goes away. Then the fields are all null. A -keep rule with no member block keeps the class, not its fields. The fields still get renamed.
Bug 2: Gson never calls your Kotlin constructor
The second bug explains why the release failure is an NPE on a non-null type. You’d expect a clear parse error instead. A Kotlin constructor is where non-null parameters get checked. The Kotlin interop docs cover this. Public functions that take non-null parameters get runtime null checks. If Gson called User(0, null, null), it would fail right there.
Gson doesn’t call it. User has no no-args constructor. Kotlin only generates one when every primary-constructor parameter has a default. Here, id and name don’t. Gson’s guide describes the fallback. When it can’t call a constructor, Gson “falls back to JDK Unsafe.” The object is created without running the constructor or any initializers. Gson then writes whatever fields it can match into that raw object and returns it.
Nothing ever enforces the non-null type on name. The getter returns the field as-is. The first code that dereferences it throws, far from the parser. In debug, the only visible damage is role losing its default. In release, R8 has also broken the name mapping. Every field goes null. The crash surfaces on whichever screen reads one first. The Gson report on Kotlin null-safety and default values was filed in February 2020. It’s still open.
Why @SerializedName alone only fixes half
Since Gson 2.11.0 in May 2024, the Gson jar ships its own R8 rules. The release notes say you may need no extra config at all. That holds only if your classes have a no-args constructor and use @SerializedName. The bundled rules file keeps every field annotated with @SerializedName. R8 may still rename the field. That no longer matters, because the annotation carries the JSON name. The file handles the constructor with a conditional rule. A class needs @SerializedName fields and an existing no-args constructor. Only then does R8 keep that constructor.
That rule has two conditions. The broken User meets only one. Annotate the fields and the JSON mapping stops depending on the names R8 picks. So id and name parse correctly. But User still has no no-args constructor. Gson still goes through Unsafe. role is still null. The release crash disappears. The debug bug ships to production.
The fix
If the project stays on Gson, fix both conditions in the model. Then make the Unsafe fallback fail loudly instead of quietly.
data class User(
@SerializedName("id") val id: Long = 0,
@SerializedName("name") val name: String = "",
@SerializedName("role") val role: String = "member",
)
val gson = GsonBuilder()
.disableJdkUnsafe()
.create()
@SerializedName lets Gson’s bundled rules keep the fields. The defaults give Kotlin a reason to generate a no-args constructor. That constructor is exactly what the bundled -if rule keeps. With it, our JVM run returned role=member. Gson’s own guide suggests disableJdkUnsafe() to catch this early. Someone may later add a data class without defaults. It will then fail in a debug build with JsonIOException: Unable to create instance of class User; usage of JDK Unsafe is disabled. It won’t wait for production. Gson versions older than 2.11.0 have no bundled rules. There you need the R8 FAQ’s -keepclassmembers rule yourself.
The defaults have a cost. A default on name means a response missing name now parses as "". It no longer fails. That hides a broken response.
The longer-term fix is to stop using a reflection-based parser for Android models. Gson’s troubleshooting guide says “Gson is not recommended on Android due to the expectation of R8 optimization.” The Android full-mode docs agree. “Avoid using Gson as it relies heavily on reflection.” kotlinx.serialization generates the serializer at compile time. There’s no field name for R8 to break and no constructor to skip.
@Serializable
data class User(
val id: Long,
val name: String,
val role: String = "member",
)
val json = Json { ignoreUnknownKeys = true }
json.decodeFromString<User>("""{"id": 42, "name": "Arsam"}""")
// User(id=42, name=Arsam, role=member)
json.decodeFromString<User>("""{"id": 42}""")
// MissingFieldException: Field 'name' is required for type with serial name 'User',
// but it was missing at path: $
A missing required field fails at parse time. A missing optional one gets its declared default. The kotlinx.serialization guide covers both under optional and required properties. On the Retrofit side, the change is swapping the converter factory.
The rule to remember
With a reflection-based parser, R8 decides whether your field names survive. Whether the constructor runs decides whether your null-safety does.
- If a release-only bug involves JSON, check the R8 mode and keep rules first. Since AGP 8.0 you’re in full mode unless someone opted out.
- If a non-null Kotlin property is
null, check whether its constructor ever ran. With Gson and no no-args constructor, it didn’t. - Treat
@SerializedNameas half of the Gson fix. The other half is a no-args constructor.disableJdkUnsafe()makes forgetting it fail in debug.
How to answer this in an interview
- Start with the build difference, not the JSON. Release builds run R8. Gson finds fields by name at runtime. R8 renames and strips what no keep rule protects. Full mode is the default since AGP 8.0. Mention that it can also remove the constructor and make the class abstract.
- Then name the second mechanism. It explains why the symptom is an NPE on a non-null type. Without a no-args constructor, Gson allocates through
Unsafe. Kotlin’s constructor null checks and defaults never run. Point out that this bug exists in debug too. Release just makes it visible. - Finish with the fix and the longer-term call. Short term, use
@SerializedNameplus defaults, withdisableJdkUnsafe()to catch regressions. Long term, move to a compile-time serializer like kotlinx.serialization. Both Gson’s maintainers and the Android docs point that way.
Common wrong answers:
- “Turn off minification for release” or “add
-dontobfuscate.” That hides the symptom by giving up shrinking and obfuscation across the whole app. It does nothing aboutUnsafeskipping the constructor. - “Add
-keep class User.” A keep rule with no member block keeps the class name, not the fields. In full mode, it turns a loud crash into silent nulls. That’s worse. - “Add
@SerializedNameand you’re done.” It fixes the R8 half.rolestill comes backnullbecause the constructor still never runs. - “Kotlin’s type system guarantees
namecan’t be null.” It guarantees that for code that goes through the constructor. Reflection plusUnsafedoesn’t go through it. - “Make every field nullable.” That compiles and stops the crash. But it spreads a parser bug into every call site as
?.and?:. A missing required field still goes unnoticed.
Related reading. Why viewModelScope.async swallows a failed API call is another network-layer failure that goes silent in production. Why EncryptedSharedPreferences throws KeyPermanentlyInvalidatedException covers a bug that only shows up on real users’ devices. GrindLoop’s Tools and Networking tracks turn failure patterns like this one into live debugging drills. Each drill comes with a reviewed fix.
Failed the interview? Not the next one.