Kotlin Variables Explained: var vs val
Find this article inside our complete Kotlin Fundamentals manual.
When starting out with Kotlin, one of the very first syntax decisions you make is choosing between var and val.
At first glance, it seems simple enough: val is for values that do not change, and var is for variables that do. But as your project scales, how you declare your variables directly impacts thread safety, code readability, and how the Kotlin compiler generates bytecode under the hood.
Here is what many developers miss: picking between var and val isn’t just about syntax preference. Mastering Kotlin variables and understanding the nuances of immutability is the foundation of writing defensive, bug-free applications.
In this comprehensive guide, you will learn:
- The fundamental differences between
val,var, andconst val. - Why a
valreference is read-only, but not always strictly immutable. - Advanced initialization strategies using
lateinitvsby lazy. - How
varandvaltranslate into JVM fields, getters, and setters under the hood. - Best practices for state management in modern Android and server-side Kotlin.
Let’s dive into the core mechanics of declaring variables in Kotlin.
The Core Mechanics: var vs val
At its simplest, Kotlin divides variable declarations into two categories: read-only references and mutable references. This explicit distinction at the variable declaration level helps developers manage state and prevent unintentional side effects.
Mutable References (var) vs. Read-Only References (val)
When you declare a variable in Kotlin, you must choose between val and var:
val(Value): Defines a read-only reference. Avalvariable can only be initialized once. Any subsequent attempt to reassign it results in a compile-time error.var(Variable): Defines a mutable reference. The variable can be reassigned to a new value as many times as needed throughout its lifecycle—provided the new value matches the original data type.
// Declaring a read-only reference with 'val'
val authorName = "Billy"
// authorName = "John" // COMPILE ERROR: Val cannot be reassigned
// Declaring a mutable reference with 'var'
var totalViews = 1000
totalViews = 1050 // Valid: var can be reassigned
Code language: Kotlin (kotlin)
Note on Type Rules for
var: Even thoughvarallows reassignment, Kotlin is still a strongly-typed language. You cannot reassign avarto a value of a different data type once inferred or declared:var score = 95 // Inferred as Int // score = "Ninety Five" // COMPILE ERROR: Type mismatch!Code language: Kotlin (kotlin)
The Crucial Nuance: Read-Only Reference vs. Immutable State
A common misconception among developers transitioning to Kotlin is assuming that declaring an object with val makes it completely immutable.
val protects the reference, not the object’s internal state.
When you assign an object instance to a val variable, you are guaranteeing that the variable cannot point to a different object in memory. However, if that object itself contains mutable properties or methods, its internal state can still be modified:
// 'val' protects the reference to the list object
val shoppingList = mutableListOf("Apples", "Bananas")
// VALID: Modifying the internal state of the object
shoppingList.add("Oranges")
println(shoppingList) // Output: [Apples, Bananas, Oranges]
// INVALID: Attempting to reassign the reference itself
// shoppingList = mutableListOf("Grapes") // COMPILE ERROR: Val cannot be reassigned
Code language: Kotlin (kotlin)
Achieving True Immutability
To guarantee complete immutability in your code, you must pair a read-only val reference with an immutable data structure:
// Truly Immutable: Read-only reference + Read-only list interface
val immutableList: List<String> = listOf("Apples", "Bananas")
// Neither reference reassignment nor modification is possible:
// immutableList.add("Oranges") // COMPILE ERROR: No 'add' method exists on List
// immutableList = listOf(...) // COMPILE ERROR: Val cannot be reassigned
Code language: Kotlin (kotlin)
Type Inference vs. Explicit Type Declarations
One of Kotlin’s most welcoming syntax features is its ability to figure out data types on its own. Thanks to powerful type inference built into kotlinc, you rarely need to explicitly state what type of data a variable holds.
However, relying on type inference isn’t always the best choice—and in some scenarios, explicit type declarations are mandatory.
How Type Inference Works Under the Hood
When you initialize a variable, the Kotlin compiler examines the expression on the right side of the = operator and automatically assigns the narrowest appropriate data type:
val title = "Kotlin Variables" // Compiler infers String
val age = 30 // Compiler infers Int
val ratio = 3.14 // Compiler infers Double
val isPublished = true // Compiler infers Boolean
Code language: Kotlin (kotlin)
Type inference isn’t limited to simple primitive literals. It also works seamlessly with function return values and object instantiations:
fun calculateTax(amount: Double): Double = amount * 0.2
val currentTax = calculateTax(100.0) // Compiler infers Double from the function's return type
Code language: Kotlin (kotlin)
Explicit Type Declarations
When you want to specify a variable’s type manually, use a colon (:) followed by the data type right after the variable name:
// Explicit Type Declaration Syntax
val accountId: String = "ACC-9082"
val maxRetries: Int = 3
val priceTag: Double = 49.99
Code language: Kotlin (kotlin)
When Explicit Type Declarations Are Mandatory
There are three key scenarios where kotlinc cannot infer a variable’s type, making explicit type annotations mandatory:
1. Deferred Initialization
If you declare a variable without immediately assigning an initial value, the compiler has no expression to analyze and will throw a compile error unless you provide an explicit type:
// INVALID: Compiler cannot determine the type
// val userId
// userId = "USR-101" // COMPILE ERROR!
// VALID: Type declared upfront, assigned later
val userId: String
userId = "USR-101" // Valid assignment
Code language: Kotlin (kotlin)
2. Broadening Types for Polymorphism
Type inference always assigns the most specific type available. If you want a variable to hold an abstract base type or an interface rather than the concrete implementation, you must declare it explicitly:
// Inferred as ArrayList<String> (Concrete Implementation)
val inferredList = arrayListOf("Apple", "Banana")
// Explicitly declared as List<String> (Interface Abstraction)
val explicitList: List<String> = arrayListOf("Apple", "Banana")
Code language: Kotlin (kotlin)
3. Resolving Primitive Ambiguity
Literal numbers default to Int for whole numbers and Double for decimals. If you need a Short, Byte, or Float without using suffix literals, specify the type explicitly:
val defaultInt = 10 // Inferred as Int
val smallNumber: Byte = 10 // Explicitly declared as Byte
val defaultDouble = 10.0 // Inferred as Double
val floatNumber: Float = 10.0f // Explicitly typed using suffix or Float type annotation
Code language: Kotlin (kotlin)
Best Practices: When to Use Explicit Types
- Use Type Inference for Local Variables: Inside function bodies, let type inference clean up code noise. If the variable name and initial value make the type obvious (e.g.,
val user = User()), explicitly writing: Useradds unnecessary clutter. - Specify Types for Public APIs: When exposing public properties or module contracts, explicit types act as documentation. They ensure you don’t accidentally leak internal implementation types if the right-hand initializer changes in the future.
Advanced Variable Declarations: const val, lateinit, and by lazy
Beyond simple val and var declarations, Kotlin provides specialized keywords and delegates for managing variable initialization timing and compilation behavior.
Knowing when to use const val, lateinit var, or by lazy allows you to write cleaner, safer, and better-optimized code.
const val: Compile-Time Constants
While a standard val creates a read-only variable whose value is determined at runtime, adding the const modifier marks a value as a compile-time constant.
// Compile-time constant (inlined everywhere it is used)
const val API_BASE_URL = "https://api.ebong-billy.site/v1"
// Runtime read-only value (evaluated during execution)
val currentTime = System.currentTimeMillis()
Code language: Kotlin (kotlin)
Rules for const val:
- Top-Level or Object Only: Must be declared at the top level of a file or inside an
object/companion objectdeclaration. - Primitive or String Types Only: Can only be initialized with a
Stringor primitive data types (Int,Double,Boolean, etc.). Custom object instances are not allowed. - No Custom Getters: Cannot have custom getter functions because the value is directly inlined into bytecode callsites at compile time.
Performance Advantage: The Kotlin compiler replaces every reference to a
const valdirectly with its literal value during compilation. This removes function call overhead and getter invocations at runtime.
lateinit var: Deferred Non-Null Initialization
Normally, non-nullable variables must be initialized in the constructor or at the point of declaration. However, frameworks like Android (via Activity lifecycle methods) or dependency injection frameworks (like Dagger/Hilt) often initialize variables later in their lifecycle.
The lateinit modifier tells the compiler: “I promise to initialize this non-nullable var before I access it.”
class UserProfileActivity : AppCompatActivity() {
// Declared without immediate initialization
private lateinit var userAdapter: UserAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialized later in the lifecycle
userAdapter = UserAdapter()
}
fun renderData() {
// Safe to access once initialized
userAdapter.submitList(...)
}
}
Code language: Kotlin (kotlin)
Safety Rules & Hazards:
varOnly: Cannot be used withvalbecause the value must be reassigned later during initialization.- Non-Nullable Types Only: Cannot be used on nullable types (
Type?) or primitive types (Int,Double, etc.). - Uninitialized Property Access: If you attempt to access a
lateinitvariable before initializing it, Kotlin throws anUninitializedPropertyAccessExceptionat runtime instead of a silentNullPointerException. You can safely check initialization state using::property.isInitialized:
if (::userAdapter.isInitialized) {
userAdapter.clear()
}
Code language: Kotlin (kotlin)
by lazy: Thread-Safe Lazy Initialization
What if you want a read-only (val) property that is expensive to create, but you don’t want to compute its value until it is actually accessed for the first time?
That is where the by lazy property delegate comes in:
class DatabaseManager {
// The database connection is ONLY created when 'database' is accessed for the first time
val database: DatabaseConnection by lazy {
println("Initializing heavy database connection...")
DatabaseConnection.connect()
}
}
Code language: Kotlin (kotlin)
Key Characteristics of by lazy:
valOnly: Works exclusively with read-only properties.- Execution on First Access: The lambda block inside
by lazy { ... }is executed exactly once on the first property call. Subsequent accesses instantly return the cached result. - Thread Safety: By default,
by lazyuses thread synchronization (LazyThreadSafetyMode.SYNCHRONIZED) to ensure the property is initialized safely across multi-threaded operations.
Comparison: const val vs lateinit var vs by lazy
| Feature | const val | lateinit var | by lazy |
| Mutability | Read-only (val) | Mutable (var) | Read-only (val) |
| Initialization Timing | Compile time | Deferred runtime | On first access (runtime) |
| Primitive Support | Yes | No (Object references only) | Yes |
| Nullability | Non-null | Non-null | Non-null or Nullable |
| Typical Use Case | Global constants, config keys | View binding, Dependency Injection | Heavy objects, database connections |
Under the Hood: How var and val Compile to Bytecode
To truly understand how Kotlin handles variables, you need to look at what kotlinc generates when compiling your code down to JVM bytecode.
In Java, declaring a public field (public int score;) exposes raw field access directly. Kotlin takes a different approach: all class properties are encapsulated by default.
Properties vs. Fields
When you write a property in a Kotlin class, the compiler automatically generates a private field alongside backing accessor methods in the resulting .class file:
valgenerates: Aprivate finalbacking field + a public getter method.vargenerates: Aprivatebacking field + a public getter method + a public setter method.
class Person {
val id: String = "P-101" // Read-only property
var name: String = "Billy" // Mutable property
}
Code language: Kotlin (kotlin)
Decompiled Java Equivalent
If you decompile the compiled .class bytecode of the Person class back into Java, here is what kotlinc actually produced behind the scenes:
public final class Person {
@NotNull
private final String id = "P-101";
@NotNull
private String name = "Billy";
// Generated getter for 'val' (no setter generated because the field is final)
@NotNull
public final String getId() {
return this.id;
}
// Generated getter for 'var'
@NotNull
public final String getName() {
return this.name;
}
// Generated setter for 'var'
public final void setName(@NotNull String var1) {
Intrinsics.checkNotNullParameter(var1, "<set-?>");
this.name = var1;
}
}
Code language: Java (java)
Key Compiler Behaviors to Notice:
- Encapsulation by Default: Kotlin automatically marks backing fields
privateand generates standard Java-style JavaBean accessors (getId(),getName(),setName()). finalKeyword onval: The backing field foridis markedprivate final, enforcing read-only constraints directly at the bytecode level.- Null-Safety Guard Injections: Notice the
Intrinsics.checkNotNullParameter()call insetName(). The compiler injects runtime null checks into generated setters so non-nullablevarproperties cannot be assignednulleven if invoked from Java interop code.
Custom Accessors Without Backing Fields
In Kotlin, you can write custom getters or setters for properties. When a val property uses a custom getter that computes its value dynamically, no backing field is generated in bytecode at all:
class Rectangle(val width: Int, val height: Int) {
// Custom getter: evaluated dynamically on every access
val area: Int
get() = width * height
}
Code language: Kotlin (kotlin)
In the compiled bytecode, area exists solely as a method: public final int getArea() { return getWidth() * getHeight(); }. Because it doesn’t store state, kotlinc omits creating a memory field for area entirely.
Best Practices for State Management in Kotlin
As your codebase grows, managing application state becomes one of your most critical responsibilities. Uncontrolled, mutable state spread across multiple threads or classes is the root cause of unpredictable race conditions, UI bugs, and memory leaks.
By adhering to a few core architectural principles, you can leverage Kotlin’s variable system to build predictable, defensive applications.
1. The Golden Rule: Immutability by Default
Always start every variable declaration with val. Only change a variable to var if you have a specific, justifiable requirement to reassign its value.
// BAD: Unnecessary 'var' opens the door to unintended side effects
var user = fetchUser()
var greeting = "Hello, " + user.name
println(greeting)
// GOOD: Immutability enforces predictability
val user = fetchUser()
val greeting = "Hello, ${user.name}"
println(greeting)
Code language: Kotlin (kotlin)
Pro Tip for Coroutines: Concurrent execution with Kotlin Coroutines is significantly safer when sharing immutable data structures across threads. Read-only (
val) properties eliminate data races without requiring complex synchronization locks or mutexes.
2. Public Read-Only, Private Mutable (Backing Properties)
When building classes or ViewModels that maintain internal state, never expose mutable variables (var) or mutable collections directly to public consumers.
Instead, encapsulate state by keeping the mutable property private and exposing a read-only (val) getter or collection interface to the outside world:
class UserViewModel {
// Private mutable list (internal state)
private val _users = mutableListOf<String>()
// Public read-only interface (external view)
val users: List<String>
get() = _users
fun addUser(name: String) {
_users.add(name)
}
}
Code language: Kotlin (kotlin)
This pattern ensures that external classes can observe state changes, but only the class owning the data can mutate it.
3. Keep Variable Scope as Small as Possible
Avoid declaring variables at the class level if they are only needed inside a single function or loop. Confining variables to the smallest possible scope reduces memory overhead and prevents stale state from lingering across operations.
// BAD: Variable scoped to class, retaining state longer than necessary
class ReportGenerator {
private var tempCount = 0
fun generate() {
tempCount = calculateItems()
// ... build report ...
}
}
// GOOD: Variable scoped strictly inside the function
class ReportGenerator {
fun generate() {
val itemCount = calculateItems()
// ... build report ...
}
}
Code language: Kotlin (kotlin)
4. Prefer Read-Only Collections over Defensive Copies
When passing lists, maps, or sets between functions, use Kotlin’s read-only interface types (List, Map, Set) rather than their mutable counterparts (MutableList, MutableMap, MutableSet).
// Returns a read-only List interface—callers cannot modify the collection
fun getActiveUsers(): List<User> {
return internalUserList.filter { it.isActive }
}
Code language: Kotlin (kotlin)
Conclusion & Actionable Takeaways
Declaring variables in Kotlin is far more than a simple syntax choice—it is a core architectural decision that directly influences thread safety, execution efficiency, and code maintainability.
By preferring immutability (val) by default, encapsulating internal state with private backing properties, and choosing specialized tools like const val, lateinit, or by lazy when appropriate, you can eliminate an entire class of runtime bugs before your app ever reaches production.
Variable Declaration Cheat Sheet
| Keyword / Delegate | Mutability | Evaluation Timing | Primary Use Case |
val | Read-only reference | Runtime | Default choice for predictable, thread-safe references. |
var | Mutable reference | Runtime | Counter state, local loop variables, or changing state. |
const val | Read-only (Inlined) | Compile time | Global configuration keys, URLs, and primitive constants. |
lateinit var | Mutable reference | Deferred runtime | Dependency injection, Android lifecycle view bindings. |
by lazy | Read-only reference | On first access | Heavy object instantiation, database connections, calculations. |
Actionable Next Steps for Developers
Here is your quick-start checklist to optimize variable usage in your Kotlin projects:
- Perform a
varAudit: Search your codebase forvardeclarations. Challenge each one: Can this be refactored into avalalongside an immutable transformation (like.map()or.filter())? - Encapsulate ViewModel & Repository State: Ensure no mutable collections (
MutableList) or mutable properties (var) are exposed publicly. Use private backing properties paired with public read-only interfaces (ListorStateFlow). - Swap Overhead
vals forconst val: Audit top-level constants and object declarations. If a string or primitivevalnever changes at runtime, convert it toconst valto eliminate getter invocation overhead at callsites. - Benchmark Initialization with
by lazy: Identify heavy startup objects (e.g., shared preference managers, API clients) and wrap them inby lazydelegates to speed up app cold-boot times.
How do you handle state management in your Kotlin projects? Do you prefer by lazy or dependency injection for deferred initialization? Let us know in the comments below!