{"id":278,"date":"2026-09-28T15:45:00","date_gmt":"2026-09-28T13:45:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=278"},"modified":"2026-09-27T16:41:02","modified_gmt":"2026-09-27T14:41:02","slug":"gson-null-fields-release-build","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/gson-null-fields-release-build\/","title":{"rendered":"Why Gson Returns Null Fields Only in Your Release Build"},"content":{"rendered":"<p>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&#8217;s constructor and mark the class abstract. The second problem exists in debug too. That&#8217;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 <code>Unsafe<\/code>. Kotlin&#8217;s null checks and default values never run. A property typed <code>String<\/code> can end up holding <code>null<\/code>. Parsing succeeds. The crash lands later, wherever the field is first read. Adding <code>@SerializedName<\/code> fixes the first bug but not the second. The fix has to cover both.<\/p>\n\n<blockquote><p>A <code>UserApi<\/code> returns the signed-in user from <code>\/me<\/code> through Retrofit&#8217;s Gson converter. Everything works on the emulator. QA installs the release build. The profile screen crashes with a <code>NullPointerException<\/code> on <code>user.name.length<\/code>. But <code>name<\/code> is declared as a non-null <code>String<\/code>. The JSON on the wire is fine. What&#8217;s wrong?<\/p><\/blockquote>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">data class User(\n    val id: Long,\n    val name: String,\n    val role: String = \"member\",\n)\n\ninterface UserApi {\n    @GET(\"me\")\n    suspend fun me(): User\n}\n\n\/\/ Retrofit.Builder()\n\/\/     .baseUrl(BASE_URL)\n\/\/     .addConverterFactory(GsonConverterFactory.create())\n\/\/     .build()<\/pre>\n\n\n<p>The response body is <code>{\"id\": 42, \"name\": \"Arsam\"}<\/code>. There&#8217;s no <code>role<\/code> key. That&#8217;s why the class gives it a default.<\/p>\n\n<h2>Gson null fields start in the debug build<\/h2>\n\n<p>Start with debug, where R8 doesn&#8217;t run. Gson still skips the constructor, for reasons covered below. So <code>role<\/code> comes back <code>null<\/code>, not <code>\"member\"<\/code>. That&#8217;s despite it being a non-null <code>String<\/code> with a default. We ran this model through Gson 2.11.0 on the JVM and got <code>User(id=42, name=Arsam, role=null)<\/code>. Nobody notices because the UI never reads <code>role<\/code> on that screen. That&#8217;s the second bug, sitting in debug the whole time. Release is where the first bug arrives and turns it into a crash.<\/p>\n\n<h2>Bug 1: R8 can&#8217;t see reflection, so it renames or deletes what Gson needs<\/h2>\n\n<p>Gson maps JSON keys to Java field names at runtime. R8 works at build time. Nothing in your code reads <code>User.name<\/code> by that literal name. So R8 treats the name as free to change. Gson&#8217;s <a href=\"https:\/\/google.github.io\/gson\/Troubleshooting.html#android-app-random-names\" target=\"_blank\" rel=\"noopener\">troubleshooting guide<\/a> describes the symptom. The app works in debug but fails in release. The fields end up with short random names like <code>a<\/code> and <code>b<\/code>. Once <code>name<\/code> becomes <code>b<\/code>, no JSON key matches it. Gson leaves every field at its JVM default. That&#8217;s <code>0<\/code> for the <code>Long<\/code> and <code>null<\/code> for both strings. R8&#8217;s compatibility mode stops here.<\/p>\n\n<p>Full mode goes further. The <a href=\"https:\/\/developer.android.com\/topic\/performance\/app-optimization\/full-mode\" target=\"_blank\" rel=\"noopener\">Android developer docs<\/a> confirm it. &#8220;R8 full mode has been the default since Android Gradle Plugin (AGP) 8.0.&#8221; The <a href=\"https:\/\/r8.googlesource.com\/r8\/+\/refs\/heads\/main\/compatibility-faq.md#r8-full-mode\" target=\"_blank\" rel=\"noopener\">R8 compatibility FAQ<\/a> 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 <code>User<\/code>. So from R8&#8217;s point of view, no code instantiates it. Gson&#8217;s guide <a href=\"https:\/\/google.github.io\/gson\/Troubleshooting.html#r8-abstract-class\" target=\"_blank\" rel=\"noopener\">names the result<\/a>. R8 can remove the no-args constructor and make the class abstract. Gson then refuses to build an abstract class. It throws a <code>JsonIOException<\/code> that starts &#8220;Abstract classes can&#8217;t be instantiated!&#8221;<\/p>\n\n<p>That loud failure is the better outcome. The quiet one is where most people end up after their first search. They add <code>-keep class User<\/code> and the crash goes away. Then the fields are all <code>null<\/code>. A <code>-keep<\/code> rule with no member block keeps the class, not its fields. The fields still get renamed.<\/p>\n\n<h2>Bug 2: Gson never calls your Kotlin constructor<\/h2>\n\n<p>The second bug explains why the release failure is an NPE on a non-null type. You&#8217;d expect a clear parse error instead. A Kotlin constructor is where non-null parameters get checked. The <a href=\"https:\/\/kotlinlang.org\/docs\/java-to-kotlin-interop.html#null-safety\" target=\"_blank\" rel=\"noopener\">Kotlin interop docs<\/a> cover this. Public functions that take non-null parameters get runtime null checks. If Gson called <code>User(0, null, null)<\/code>, it would fail right there.<\/p>\n\n<p>Gson doesn&#8217;t call it. <code>User<\/code> has no no-args constructor. Kotlin only generates one when <a href=\"https:\/\/kotlinlang.org\/docs\/classes.html\" target=\"_blank\" rel=\"noopener\">every primary-constructor parameter has a default<\/a>. Here, <code>id<\/code> and <code>name<\/code> don&#8217;t. Gson&#8217;s guide describes the <a href=\"https:\/\/google.github.io\/gson\/Troubleshooting.html#default-field-values-missing\" target=\"_blank\" rel=\"noopener\">fallback<\/a>. When it can&#8217;t call a constructor, Gson &#8220;falls back to JDK <code>Unsafe<\/code>.&#8221; 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.<\/p>\n\n<p>Nothing ever enforces the non-null type on <code>name<\/code>. 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 <code>role<\/code> 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 <a href=\"https:\/\/github.com\/google\/gson\/issues\/1657\" target=\"_blank\" rel=\"noopener\">Kotlin null-safety and default values<\/a> was filed in February 2020. It&#8217;s still open.<\/p>\n\n<h2>Why <code>@SerializedName<\/code> alone only fixes half<\/h2>\n\n<p>Since <a href=\"https:\/\/github.com\/google\/gson\/releases\/tag\/gson-parent-2.11.0\" target=\"_blank\" rel=\"noopener\">Gson 2.11.0<\/a> 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 <code>@SerializedName<\/code>. The <a href=\"https:\/\/github.com\/google\/gson\/blob\/main\/gson\/src\/main\/resources\/META-INF\/proguard\/gson.pro\" target=\"_blank\" rel=\"noopener\">bundled rules file<\/a> keeps every field annotated with <code>@SerializedName<\/code>. 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 <code>@SerializedName<\/code> fields and an existing no-args constructor. Only then does R8 keep that constructor.<\/p>\n\n<p>That rule has two conditions. The broken <code>User<\/code> meets only one. Annotate the fields and the JSON mapping stops depending on the names R8 picks. So <code>id<\/code> and <code>name<\/code> parse correctly. But <code>User<\/code> still has no no-args constructor. Gson still goes through <code>Unsafe<\/code>. <code>role<\/code> is still <code>null<\/code>. The release crash disappears. The debug bug ships to production.<\/p>\n\n<h2>The fix<\/h2>\n\n<p>If the project stays on Gson, fix both conditions in the model. Then make the <code>Unsafe<\/code> fallback fail loudly instead of quietly.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">data class User(\n    @SerializedName(\"id\") val id: Long = 0,\n    @SerializedName(\"name\") val name: String = \"\",\n    @SerializedName(\"role\") val role: String = \"member\",\n)\n\nval gson = GsonBuilder()\n    .disableJdkUnsafe()\n    .create()<\/pre>\n\n\n<p><code>@SerializedName<\/code> lets Gson&#8217;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 <code>-if<\/code> rule keeps. With it, our JVM run returned <code>role=member<\/code>. Gson&#8217;s own guide suggests <a href=\"https:\/\/google.github.io\/gson\/Troubleshooting.html#default-field-values-missing\" target=\"_blank\" rel=\"noopener\"><code>disableJdkUnsafe()<\/code><\/a> to catch this early. Someone may later add a data class without defaults. It will then fail in a debug build with <code>JsonIOException: Unable to create instance of class User; usage of JDK Unsafe is disabled<\/code>. It won&#8217;t wait for production. Gson versions older than 2.11.0 have no bundled rules. There you need the R8 FAQ&#8217;s <a href=\"https:\/\/r8.googlesource.com\/r8\/+\/refs\/heads\/main\/compatibility-faq.md#member-in-a-data-object-is-always\" target=\"_blank\" rel=\"noopener\"><code>-keepclassmembers<\/code> rule<\/a> yourself.<\/p>\n\n<p>The defaults have a cost. A default on <code>name<\/code> means a response missing <code>name<\/code> now parses as <code>\"\"<\/code>. It no longer fails. That hides a broken response.<\/p>\n\n<p>The longer-term fix is to stop using a reflection-based parser for Android models. Gson&#8217;s <a href=\"https:\/\/google.github.io\/gson\/Troubleshooting.html#proguard-r8\" target=\"_blank\" rel=\"noopener\">troubleshooting guide<\/a> says &#8220;Gson is not recommended on Android due to the expectation of R8 optimization.&#8221; The <a href=\"https:\/\/developer.android.com\/topic\/performance\/app-optimization\/full-mode\" target=\"_blank\" rel=\"noopener\">Android full-mode docs<\/a> agree. &#8220;Avoid using Gson as it relies heavily on reflection.&#8221; kotlinx.serialization generates the serializer at compile time. There&#8217;s no field name for R8 to break and no constructor to skip.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">@Serializable\ndata class User(\n    val id: Long,\n    val name: String,\n    val role: String = \"member\",\n)\n\nval json = Json { ignoreUnknownKeys = true }\n\njson.decodeFromString&lt;User&gt;(\"\"\"{\"id\": 42, \"name\": \"Arsam\"}\"\"\")\n\/\/ User(id=42, name=Arsam, role=member)\n\njson.decodeFromString&lt;User&gt;(\"\"\"{\"id\": 42}\"\"\")\n\/\/ MissingFieldException: Field 'name' is required for type with serial name 'User',\n\/\/ but it was missing at path: $<\/pre>\n\n\n<p>A missing required field fails at parse time. A missing optional one gets its declared default. The <a href=\"https:\/\/github.com\/Kotlin\/kotlinx.serialization\/blob\/master\/docs\/basic-serialization.md\" target=\"_blank\" rel=\"noopener\">kotlinx.serialization guide<\/a> covers both under optional and required properties. On the Retrofit side, the change is swapping the converter factory.<\/p>\n\n<h2>The rule to remember<\/h2>\n\n<p><strong>With a reflection-based parser, R8 decides whether your field names survive. Whether the constructor runs decides whether your null-safety does.<\/strong><\/p>\n\n<ul>\n<li>If a release-only bug involves JSON, check the R8 mode and keep rules first. Since AGP 8.0 you&#8217;re in full mode unless someone opted out.<\/li>\n<li>If a non-null Kotlin property is <code>null<\/code>, check whether its constructor ever ran. With Gson and no no-args constructor, it didn&#8217;t.<\/li>\n<li>Treat <code>@SerializedName<\/code> as half of the Gson fix. The other half is a no-args constructor. <code>disableJdkUnsafe()<\/code> makes forgetting it fail in debug.<\/li>\n<\/ul>\n\n<h2>How to answer this in an interview<\/h2>\n\n<ol>\n<li>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.<\/li>\n<li>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 <code>Unsafe<\/code>. Kotlin&#8217;s constructor null checks and defaults never run. Point out that this bug exists in debug too. Release just makes it visible.<\/li>\n<li>Finish with the fix and the longer-term call. Short term, use <code>@SerializedName<\/code> plus defaults, with <code>disableJdkUnsafe()<\/code> to catch regressions. Long term, move to a compile-time serializer like kotlinx.serialization. Both Gson&#8217;s maintainers and the Android docs point that way.<\/li>\n<\/ol>\n\n<p><strong>Common wrong answers:<\/strong><\/p>\n\n<ul>\n<li>&#8220;Turn off minification for release&#8221; or &#8220;add <code>-dontobfuscate<\/code>.&#8221; That hides the symptom by giving up shrinking and obfuscation across the whole app. It does nothing about <code>Unsafe<\/code> skipping the constructor.<\/li>\n<li>&#8220;Add <code>-keep class User<\/code>.&#8221; 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&#8217;s worse.<\/li>\n<li>&#8220;Add <code>@SerializedName<\/code> and you&#8217;re done.&#8221; It fixes the R8 half. <code>role<\/code> still comes back <code>null<\/code> because the constructor still never runs.<\/li>\n<li>&#8220;Kotlin&#8217;s type system guarantees <code>name<\/code> can&#8217;t be null.&#8221; It guarantees that for code that goes through the constructor. Reflection plus <code>Unsafe<\/code> doesn&#8217;t go through it.<\/li>\n<li>&#8220;Make every field nullable.&#8221; That compiles and stops the crash. But it spreads a parser bug into every call site as <code>?.<\/code> and <code>?:<\/code>. A missing required field still goes unnoticed.<\/li>\n<\/ul>\n\n<hr\/>\n\n<p><em>Related reading. <a href=\"https:\/\/grindloop.ai\/blog\/viewmodelscope-async-swallows-api-call\/\">Why viewModelScope.async swallows a failed API call<\/a> is another network-layer failure that goes silent in production. <a href=\"https:\/\/grindloop.ai\/blog\/encryptedsharedpreferences-keypermanentlyinvalidatedexception\/\">Why EncryptedSharedPreferences throws KeyPermanentlyInvalidatedException<\/a> covers a bug that only shows up on real users&#8217; devices. GrindLoop&#8217;s Tools and Networking tracks turn failure patterns like this one into live debugging drills. Each drill comes with a reviewed fix.<\/em><\/p>\n\n<p><strong>Failed the interview? Not the next one.<\/strong><\/p>","protected":false},"excerpt":{"rendered":"<p>A Kotlin data class parsed by Gson works in debug and comes back null in release. Here&#8217;s the two-bug reason (R8 plus Gson skipping your constructor), the fix, and the interview answer.<\/p>\n","protected":false},"author":2,"featured_media":282,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Gson Null Fields Only in Release Build? The 2 Bugs Behind It","rank_math_description":"Gson null fields only in your release build? R8 renames what Gson reads by reflection, and Gson skips your Kotlin constructor. How to fix both in a data class.","rank_math_focus_keyword":"gson null fields","footnotes":""},"categories":[9,8],"tags":[15,86,16,12,57,85,45],"class_list":["post-278","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-tools","tag-android","tag-gson","tag-interview-prep","tag-kotlin","tag-networking","tag-r8","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/278","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=278"}],"version-history":[{"count":26,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/278\/revisions"}],"predecessor-version":[{"id":700,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/278\/revisions\/700"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/282"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=278"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=278"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=278"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}