Kotlin Variables Explained: var vs val

Kotlin variables

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, and const val.
  • Why a val reference is read-only, but not always strictly immutable.
  • Advanced initialization strategies using lateinit vs by lazy.
  • How var and val translate 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. A val variable 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 though var allows reassignment, Kotlin is still a strongly-typed language. You cannot reassign a var to 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 : User adds 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:

  1. Top-Level or Object Only: Must be declared at the top level of a file or inside an object / companion object declaration.
  2. Primitive or String Types Only: Can only be initialized with a String or primitive data types (Int, Double, Boolean, etc.). Custom object instances are not allowed.
  3. 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 val directly 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:

  • var Only: Cannot be used with val because 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 lateinit variable before initializing it, Kotlin throws an UninitializedPropertyAccessException at runtime instead of a silent NullPointerException. 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:

  1. val Only: Works exclusively with read-only properties.
  2. 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.
  3. Thread Safety: By default, by lazy uses thread synchronization (LazyThreadSafetyMode.SYNCHRONIZED) to ensure the property is initialized safely across multi-threaded operations.

Comparison: const val vs lateinit var vs by lazy

Featureconst vallateinit varby lazy
MutabilityRead-only (val)Mutable (var)Read-only (val)
Initialization TimingCompile timeDeferred runtimeOn first access (runtime)
Primitive SupportYesNo (Object references only)Yes
NullabilityNon-nullNon-nullNon-null or Nullable
Typical Use CaseGlobal constants, config keysView binding, Dependency InjectionHeavy 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:

  • val generates: A private final backing field + a public getter method.
  • var generates: A private backing 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:

  1. Encapsulation by Default: Kotlin automatically marks backing fields private and generates standard Java-style JavaBean accessors (getId(), getName(), setName()).
  2. final Keyword on val: The backing field for id is marked private final, enforcing read-only constraints directly at the bytecode level.
  3. Null-Safety Guard Injections: Notice the Intrinsics.checkNotNullParameter() call in setName(). The compiler injects runtime null checks into generated setters so non-nullable var properties cannot be assigned null even 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 / DelegateMutabilityEvaluation TimingPrimary Use Case
valRead-only referenceRuntimeDefault choice for predictable, thread-safe references.
varMutable referenceRuntimeCounter state, local loop variables, or changing state.
const valRead-only (Inlined)Compile timeGlobal configuration keys, URLs, and primitive constants.
lateinit varMutable referenceDeferred runtimeDependency injection, Android lifecycle view bindings.
by lazyRead-only referenceOn first accessHeavy object instantiation, database connections, calculations.

Actionable Next Steps for Developers

Here is your quick-start checklist to optimize variable usage in your Kotlin projects:

  1. Perform a var Audit: Search your codebase for var declarations. Challenge each one: Can this be refactored into a val alongside an immutable transformation (like .map() or .filter())?
  2. 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 (List or StateFlow).
  3. Swap Overhead vals for const val: Audit top-level constants and object declarations. If a string or primitive val never changes at runtime, convert it to const val to eliminate getter invocation overhead at callsites.
  4. Benchmark Initialization with by lazy: Identify heavy startup objects (e.g., shared preference managers, API clients) and wrap them in by lazy delegates 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!

You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *