Scala Interview Questions and Answers
Last updated:
Check out 45 of the most common Scala interview questions, then take an AI-powered practice interview
Q1What is the difference between val, var, lazy val and def in Scala?
BasicLanguage Fundamentals
Answer
All four bind a name, but they differ in when the right-hand side runs and how often. A val is evaluated exactly once, eagerly, at the point the enclosing object or block is constructed, and the binding cannot be reassigned. A var is also evaluated eagerly but can be reassigned later, which makes it the only one of the four that participates in shared mutable state problems.
A def is a method: the body runs every single time the name is referenced, so def random = Math.random() gives a different number on each call. A lazy val is evaluated at most once, on first access, and the result is then cached for the lifetime of the instance. Interviewers usually follow up with thread safety.
In Scala 2 a lazy val compiles to a bitmap field plus a synchronized block on the enclosing instance, which means two lazy vals in two objects that reference each other can deadlock. Scala 3 replaced this with a CAS-based scheme in scala.runtime.LazyVals that no longer locks the enclosing instance, removing the classic lazy-val deadlock but not the cost: a lazy val still adds a field and a volatile read on every access. The practical rules are that val is the default, lazy val is for expensive values you may never need or for breaking initialisation-order cycles inside a trait, def is for genuinely computed results, and var almost always signals a design you can refactor.
class Config:
val eager = { println("eager"); 1 } // once, at construction
lazy val cached = { println("lazy"); 2 } // once, on first access
def each = { println("def"); 3 } // on every reference
var mutable = 4 // reassignable
@main def run(): Unit =
val c = Config() // prints "eager"
c.each; c.each // prints "def" twice
c.cached; c.cached // prints "lazy" once
c.mutable = 5
Key Points
- val: eager, once, immutable binding (the default choice)
- lazy val: at most once, on first access, then cached
- def: re-evaluated on every reference
- Scala 3 lazy vals use scala.runtime.LazyVals (CAS) instead of synchronizing on this
- lazy val is the standard fix for trait initialisation-order NPEs
Q2What does the compiler generate for a case class, and when should you avoid one?
BasicLanguage Fundamentals
Answer
Declaring case class User(id: Long, name: String) makes the compiler synthesise a large amount of code for you: the constructor parameters become public vals, you get a companion object with apply and unapply (so the class works in pattern matches), a structural equals and a matching hashCode derived from all fields in the first parameter list, a readable toString, a copy method with defaults for every field, and the class extends Product and Serializable. In Scala 3 the same declaration also gives you canEqual, productElementName access, and works out of the box with Mirror-based type class derivation. That auto-derived equality is exactly what makes case classes safe as map keys and safe to compare in tests without writing assertions field by field.
The failure modes are worth naming in an interview. First, equals is structural but only over the first parameter list, so case class Order(id: Long)(val amount: BigDecimal) silently ignores amount in equality. Second, if you put a var or an Array in a case class, hashCode changes after you mutate it, and the instance becomes unreachable inside a HashMap or HashSet.
Third, Array fields compare by reference because Java arrays do, so two case classes holding equal arrays are not equal. Fourth, a case class with more than a couple of dozen fields generates a lot of bytecode and slows compilation noticeably. Use a plain class when you need custom equality, encapsulation of the constructor, or a mutable object with identity semantics.
case class User(id: Long, name: String, tags: List[String] = Nil)
val a = User(1L, "Asha")
val b = a.copy(name = "Asha Rao") // structural copy, id preserved
a == User(1L, "Asha") // true: structural equality
a.productArity // 3
a.productElementName(1) // "name"
val User(id, name, _) = b // unapply in a pattern binding
// Gotcha: arrays compare by reference
case class Blob(bytes: Array[Byte])
Blob(Array[Byte](1)) == Blob(Array[Byte](1)) // false
Key Points
- Generates apply, unapply, equals, hashCode, toString, copy, Product
- Equality covers only the first parameter list
- var or Array fields break HashMap/HashSet lookups
- Prefer a plain class when you need custom equality or identity semantics
Q3How does Option remove null, and what are getOrElse, fold and collect used for?
BasicLanguage Fundamentals
Answer
Option[A] is a sealed abstract class with exactly two subtypes, Some(value) and None, so absence becomes a value the type system can see rather than a runtime landmine. Because it is sealed, a match on an Option that forgets None produces an exhaustivity warning, and with -Xfatal-warnings on your build that becomes a compile error. Option is also a monad, so it composes: map transforms the value if present, flatMap chains another Option-returning call, filter turns a present value into None when a predicate fails, and for-comprehensions over several Options short-circuit to None at the first miss. getOrElse supplies a default lazily (the argument is by-name, so it is not evaluated unless needed), orElse falls back to another Option, and fold handles both branches in one expression: opt.fold(default)(f). collect combines a partial function with filtering, which is useful when you want to both test and transform.
The important interview point is what Option does not do: it does not protect you at the Java boundary. Any Java library, JDBC driver, Jackson binding or Spark UDF can hand you a null that is statically typed as String, and pattern matching on that will not save you. Wrap every foreign value with Option(x), which returns None for null, rather than Some(x), which happily wraps a null and produces a NullPointerException three call frames later. Also avoid Option.get in production code: it is the direct equivalent of the null dereference you were trying to eliminate.
def lookup(id: Long): Option[String] = if id == 1 then Some("Asha") else None
lookup(1).map(_.toUpperCase) // Some("ASHA")
lookup(2).getOrElse("unknown") // "unknown" (by-name default)
lookup(1).fold("missing")(n => s"found $n") // "found Asha"
lookup(1).collect { case n if n.length > 3 => n.length } // Some(4)
// Java interop: Option(...) is null-safe, Some(...) is not
val fromJava: String = System.getenv("MISSING_KEY") // null
Option(fromJava).getOrElse("default") // "default"
Some(fromJava).get.length // NullPointerException
for
a <- lookup(1)
b <- lookup(2) // short-circuits here
yield a + b // None
Q4How does pattern matching work with sealed traits, and why does sealed matter for exhaustivity?
BasicPattern Matching
Answer
A match expression tries each case in order and evaluates the first whose pattern matches. Patterns can be literals, typed patterns (case s: String), constructor patterns backed by unapply (case User(id, _)), sequence patterns (case head :: tail), tuple patterns, alternatives with |, and guarded patterns with if. Binding with @ lets you capture a whole matched subvalue while still destructuring it.
The sealed keyword is what makes this safe. Marking a trait or abstract class sealed restricts direct subclasses to the same file, so the compiler knows the complete set of shapes and can warn when your match misses one. Turn that warning into a build failure with -Xfatal-warnings (Scala 2) or -Werror plus -Xfatal-warnings in Scala 3, and adding a new variant to your ADT becomes a compile error in every place that handles it, which is precisely the refactoring safety net people come to Scala for.
Two things break exhaustivity checking in practice. Catch-all cases such as case _ => silence the warning permanently, so avoid them on domain ADTs and reserve them for genuinely open types. Guards also defeat the checker, because the compiler cannot prove that if x > 0 plus if x <= 0 covers everything; it will still warn.
If a match throws at runtime you get a scala.MatchError containing the unmatched value, which is a useful log line but a bad way to discover a missing case. In Scala 3 the same guarantee applies to enum, which is just a sealed hierarchy with better syntax.
sealed trait Payment
case class Upi(vpa: String) extends Payment
case class Card(last4: String, network: String) extends Payment
case object Cash extends Payment
def fee(p: Payment): BigDecimal = p match
case Upi(_) => BigDecimal(0)
case c @ Card(_, "RUPAY") => BigDecimal("0.60")
case Card(_, _) => BigDecimal("1.90")
case Cash => BigDecimal(0)
// Delete the Cash case and compile with -Werror -Xfatal-warnings:
// error: match may not be exhaustive. It would fail on: Cash
Key Points
- sealed limits subclasses to one file so the compiler can check exhaustivity
- Enable -Werror / -Xfatal-warnings to turn missing cases into build failures
- case _ => and guards both defeat exhaustivity checking
- An unmatched value throws scala.MatchError at runtime
Q5Trait versus abstract class in Scala, and how does linearization resolve conflicts?
BasicOOP and Types
Answer
Use a trait by default. Traits can be mixed in multiple times, can hold abstract and concrete members, and since Scala 3 they can also take constructor parameters, which removed the main historical reason to reach for an abstract class. An abstract class is still preferable in two situations: when you need to interoperate cleanly with Java code that expects a class hierarchy, and when you want a single constructor with parameters that all subclasses must call in Scala 2.
Because a class can mix in many traits, Scala needs a deterministic rule for which implementation wins, and that rule is linearization. The compiler flattens the inheritance graph into a linear order: the class itself first, then its mixins from right to left, then their parents, with duplicates removed keeping the rightmost occurrence. Method dispatch and super both follow this order, which is why super in a trait does not mean the trait's declared parent but the next type in the linearization of the concrete class.
That is the mechanism behind stackable modification: a chain of traits each calling super produces a pipeline whose order depends on mixin order at the use site. Interviewers love this because candidates who have only used traits as interfaces get the order backwards. The practical gotcha is that reordering mixins silently changes behaviour with no compile error, so in production code keep stackable traits shallow, name them so the intended order is obvious, and write a test that asserts the composed behaviour rather than trusting the reader to compute the linearization in their head.
trait Logging:
def handle(msg: String): String = msg
trait Timestamped extends Logging:
override def handle(msg: String): String = super.handle(s"[ts] $msg")
trait Uppercase extends Logging:
override def handle(msg: String): String = super.handle(msg.toUpperCase)
class A extends Logging, Timestamped, Uppercase
class B extends Logging, Uppercase, Timestamped
(new A).handle("hi") // "[ts] HI" (Uppercase runs first)
(new B).handle("hi") // "[TS] HI" (Timestamped runs first)
Q6How do you choose between List, Vector, ArraySeq and mutable collections?
BasicCollections
Answer
scala.collection.immutable is imported by default, so List, Vector, Set and Map mean the immutable versions unless you explicitly import scala.collection.mutable. List is a singly linked cons list: prepend and head are O(1), but apply(i), length and append are O(n), and every element costs an object header plus a pointer. It is the right choice when you build by prepending and consume by pattern matching on head :: tail.
Vector is a radix-balanced finger tree with effectively constant-time indexed access, update, append and prepend, and it is the sane default for anything that is randomly accessed or larger than a few hundred elements. ArraySeq (immutable since 2.13) wraps a primitive array, so ArraySeq[Int] stores unboxed ints and is dramatically more memory-efficient than List[Int] or Vector[Int], which box every element. For numeric workloads that is often a five to ten times memory difference, which matters when a Spark executor is fighting for heap.
Mutable collections belong inside a method body where the mutation cannot escape: building a large result with mutable.ArrayBuffer or mutable.HashMap and then calling .toList or .toMap at the end is idiomatic and fast, and reviewers accept it. What is not acceptable is a mutable.HashMap held as a field on a shared singleton, because Scala's mutable collections are not thread-safe and concurrent writes corrupt them silently rather than throwing. Use java.util.concurrent.ConcurrentHashMap through scala.jdk.CollectionConverters, or an AtomicReference holding an immutable Map, when several threads are involved.
import scala.collection.mutable
import scala.collection.immutable.ArraySeq
val l = List(1, 2, 3)
0 :: l // O(1) prepend
l(2) // O(n) indexed access
val v = Vector.tabulate(1_000_000)(identity)
v(999_999) // effectively O(1)
v.updated(0, 42) // effectively O(1), returns a new Vector
val a = ArraySeq(1, 2, 3) // unboxed Ints under the hood
// Mutable buffer, scoped to the method, frozen on the way out
def parse(rows: Iterator[String]): List[Int] =
val buf = mutable.ArrayBuffer.empty[Int]
rows.foreach(r => buf += r.toInt)
buf.toList
Key Points
- List: O(1) prepend/head, O(n) index and append
- Vector: effectively constant time for index, update, append, prepend
- ArraySeq[Int] avoids boxing; List[Int] and Vector[Int] do not
- mutable collections are not thread-safe; never share them across threads
Q7Why can foldRight throw StackOverflowError while foldLeft does not?
BasicCollections
Answer
foldLeft is implemented as a while loop over an iterator with an accumulator variable, so it uses constant stack regardless of collection size. foldRight on a strict linear sequence has to reach the last element before it can apply the function, and the standard library implements that for List by recursing to the end and combining on the way back, which pushes one frame per element. On a default JVM thread stack of about 512 KB to 1 MB you will typically hit StackOverflowError somewhere between roughly 10,000 and 100,000 elements, and the exact number moves with JIT inlining, so this is a bug that passes local tests with 1,000 rows and dies in production with a million. The standard library mitigates this in two ways: for List, foldRight reverses the list and runs foldLeft with flipped arguments when it can, and IndexedSeq implementations like Vector and ArraySeq iterate backwards without recursion.
But foldRight on a LazyList, a Stream in older code, or a user-defined recursive structure still recurses. The correct answers in an interview are: prefer foldLeft when the operation is associative or you can flip it, use reverse then foldLeft when you genuinely need right-to-left order, use a lazy or by-name accumulator (Cats provides Eval and Foldable#foldRight for exactly this) when you need short-circuiting from the right, and mark your own recursive helpers @tailrec so the compiler proves the loop is flattened instead of you hoping it is.
val big = (1 to 5_000_000).toList
big.foldLeft(0L)(_ + _) // fine: compiled to a while loop
// big.foldRight(0L)(_ + _) // risk of StackOverflowError on some shapes
// Safe right-to-left: reverse then foldLeft
big.reverse.foldLeft(List.empty[Int])((acc, x) => x :: acc)
// Your own recursion must be tail recursive
import scala.annotation.tailrec
@tailrec def sum(xs: List[Long], acc: Long = 0L): Long = xs match
case Nil => acc
case h :: t => sum(t, acc + h) // compiles to a jump, constant stack
Q8How does a for-comprehension desugar, and why does that matter?
BasicLanguage Fundamentals
Answer
A for-comprehension is pure syntax sugar that the compiler rewrites before typing. The rules are mechanical: the last generator plus yield becomes map, every earlier generator becomes flatMap, an if guard becomes withFilter (falling back to filter if withFilter does not exist), a pattern on the left of an arrow also inserts a withFilter to drop non-matching elements, and a comprehension without yield becomes foreach. That means for works over anything providing map, flatMap and withFilter, which is why the same syntax reads uniformly over List, Option, Either, Try, Future, cats.effect.IO, ZIO and fs2.Stream, none of which share a common supertype.
Knowing the desugaring pays off in three concrete ways. First, it explains type errors: mixing an Option generator and a List generator in one for fails to compile because flatMap on Option cannot accept a List-returning function, and the error message points at the desugared call, not your source line. Second, it explains laziness and effect ordering: a for over Future starts each Future when its flatMap runs, so futures created inside the comprehension run sequentially, while futures created before it run in parallel.
That single fact is behind a large share of slow Scala services. Third, it explains why withFilter matters for performance: filter materialises an intermediate collection, withFilter does not, so guards inside a for over a large collection are cheaper than the equivalent chained filter call.
// Source
val r = for
a <- List(1, 2, 3)
b <- List(10, 20)
if a * b > 20
yield a * b
// Desugared by the compiler
val r2 = List(1, 2, 3).flatMap(a =>
List(10, 20).withFilter(b => a * b > 20).map(b => a * b))
// Sequential vs parallel Futures: the desugaring is the whole story
for { x <- callA(); y <- callB() } yield x + y // callB starts after callA
val fa = callA(); val fb = callB() // both already running
for { x <- fa; y <- fb } yield x + y // truly parallel
Key Points
- Last generator plus yield becomes map; earlier generators become flatMap
- if guards become withFilter, which avoids materialising an intermediate
- No yield means foreach
- Futures created inside a for run sequentially; hoist them out to parallelise
Q9What is a companion object and what do apply and unapply do in it?
BasicOOP and Types
Answer
An object in Scala is a singleton: exactly one instance, created lazily on first access, initialised in a thread-safe way by the JVM class initialiser. When an object shares its name and file with a class or trait, it is that type's companion, and the two can see each other's private members. The companion is where Scala puts everything Java would mark static: factory methods, constants, implicit or given instances, and Codec or Format definitions for JSON libraries.
Two method names in a companion are special to the compiler. apply lets you write User(1, "Asha") instead of new User(...), because Foo(args) is rewritten to Foo.apply(args) whenever Foo is a value rather than a type. unapply is the extractor used in pattern matching: case User(id, name) calls User.unapply on the scrutinee and destructures the result. In Scala 3, unapply can return a product type, an Option, or a Boolean, and can also be defined to return the matched value directly for irrefutable patterns. Case classes get both for free, which is why they work in matches without extra code.
A well-known interview follow-up is where the compiler looks for a given or implicit without an explicit import: it searches the lexical scope first, then the implicit scope, which includes the companion objects of the types involved. That is why putting a given Encoder[User] inside object User makes JSON encoding work everywhere in your codebase with no import at all, and it is the single most useful placement rule to know.
class Money private (val paise: Long):
override def toString = f"₹${paise / 100.0}%.2f"
object Money:
def apply(rupees: BigDecimal): Money = new Money((rupees * 100).toLong)
def unapply(m: Money): Option[Long] = Some(m.paise)
// Placed in the companion, so it is found with no import
given Ordering[Money] = Ordering.by(_.paise)
val m = Money(199.50) // Money.apply
m match
case Money(p) => println(p) // Money.unapply -> 19950
List(Money(5), Money(1)).sorted // uses the companion's given Ordering
Q10When do you use Either versus Try versus throwing an exception?
BasicError Handling
Answer
Try[A] models a computation that may throw, with Success(a) and Failure(throwable). Try(expr) catches only non-fatal throwables, so OutOfMemoryError, StackOverflowError, ThreadDeath and other things listed in scala.util.control.NonFatal propagate rather than being swallowed, which is correct behaviour. Use Try at the boundary with Java libraries and IO that genuinely throws: JDBC calls, file reads, parsing with a Java library.
Either[E, A] models a computation with an expected, domain-level failure whose type you choose. Since Scala 2.12 Either is right-biased, so map, flatMap and for-comprehensions operate on Right and short-circuit on Left, which makes it the standard return type for validation and business rules. The advantage over Try is that the error side is your own sealed ADT rather than an opaque Throwable, so callers can pattern match exhaustively on InsufficientBalance, AccountFrozen or KycPending and the compiler tells them when you add a new failure.
Throwing is still right for genuinely exceptional conditions: programming errors, invariant violations, and anything the caller cannot reasonably handle. The common mistake in Indian interview code samples is Either[String, A], where the error is a message string. That compiles, but it means every caller matches on message text, and translating errors into HTTP status codes turns into string parsing.
Use a sealed trait for the error channel. If you need to accumulate multiple validation errors rather than stop at the first, Either short-circuits by design, so reach for cats.data.Validated or ValidatedNel instead.
import scala.util.{Try, Success, Failure}
// Try at a throwing boundary
val parsed: Try[Int] = Try("42x".toInt)
parsed.recover { case _: NumberFormatException => 0 } // Success(0)
// Either with a domain ADT for the error channel
sealed trait DebitError
case object InsufficientBalance extends DebitError
case class AccountFrozen(reason: String) extends DebitError
def debit(bal: Long, amt: Long): Either[DebitError, Long] =
if amt > bal then Left(InsufficientBalance) else Right(bal - amt)
val out = for
a <- debit(1000, 400)
b <- debit(a, 900) // Left(InsufficientBalance); stops here
yield b
out.left.map {
case InsufficientBalance => 402
case AccountFrozen(_) => 423
}
Key Points
- Try catches only NonFatal throwables; use it at Java and IO boundaries
- Either is right-biased since 2.12 and short-circuits on Left
- Model the Left side as a sealed ADT, never as a String
- Use cats.data.ValidatedNel when you need to accumulate errors instead of short-circuiting
Q11What do the s, f and raw interpolators do, and how do you write your own?
BasicLanguage Fundamentals
Answer
String interpolation is desugared into a call on StringContext. Writing s"hi $name" becomes StringContext("hi ", "").s(name), which means the prefix before the quote is just a method on StringContext and you can add your own. The three built-ins are s, which calls toString on each argument and processes escape sequences; f, which is type-checked at compile time against printf-style format specifiers, so f"$count%d" fails to compile if count is a String, and f"${amt}%.2f" rounds a Double to two decimals; and raw, which interpolates but does not process escapes, so raw"a\nb" contains a literal backslash and n.
That compile-time checking on f is the reason to prefer it over string concatenation for anything numeric or currency-formatted. Custom interpolators are how libraries get compile-time safety for embedded languages. Doobie's sql interpolator builds a parameterised JDBC statement so interpolated values become bind parameters rather than concatenated SQL, which removes SQL injection by construction.
Circe has json, and several libraries expose interpolators for URIs and durations. You define one by adding an extension method on StringContext, and in Scala 3 you can make it a macro so malformed literals fail at compile time. Two practical notes for interviews: interpolation inside a logging call still builds the string even when the log level is off, so guard hot paths or use a logging API that takes by-name arguments; and inside a triple-quoted block, stripMargin plus the s interpolator is the standard way to embed multi-line SQL or JSON without escaping.
val name = "Asha"; val amt = 1234.5; val n = 7
s"Hello $name, you have $n items" // simple toString interpolation
f"Total: ₹$amt%.2f across $n%d orders" // compile-time checked format
raw"path\nnot-a-newline" // escapes left alone
s"""SELECT id, name
|FROM users
|WHERE city = '$name'""".stripMargin // do NOT do this for SQL
// Custom interpolator: extension method on StringContext
extension (sc: StringContext)
def hex(args: Any*): Int =
Integer.parseInt(sc.s(args*).stripPrefix("0x"), 16)
hex"0xff" // 255
Q12How does Scala 3 enum differ from the Scala 2 sealed trait plus case object pattern?
BasicScala 3
Answer
In Scala 2 an algebraic data type is written as a sealed trait with case classes and case objects extending it, and a simple enumeration is written either the same way or with the largely disliked scala.Enumeration class, which erases to a single type and gives you no exhaustivity checking. Scala 3's enum keyword replaces both. A parameterless enum such as enum Currency { case INR, USD, EUR } gives you a sealed hierarchy plus generated helpers: Currency.values returns an Array of all cases, Currency.valueOf("INR") parses by name, and each case has ordinal and toString.
A parameterised enum such as enum Shape { case Circle(r: Double); case Rect(w: Double, h: Double) } is exactly an ADT, the cases are case classes, and matches on it are exhaustivity-checked. You can also add methods to the enum body, give cases explicit constructor arguments with extends, and attach derives clauses for automatic type class instances. Two details interviewers probe.
First, a parameterless enum case compiles to a singleton value, so it is cheap and comparable with eq, while a parameterised case is a real case class. Second, Scala 3 enums interoperate with Java enums only if you extend java.lang.Enum explicitly, which the compiler supports for parameterless enums; without that, a Scala 3 enum is not usable in a Java switch or in JPA annotations. That matters when your Scala service shares a data model with Java code, which is common in banks running mixed JVM stacks.
// Scala 2 style
sealed trait Status
case object Active extends Status
case object Suspended extends Status
// Scala 3 enum: same guarantees, plus values / valueOf / ordinal
enum Status derives CanEqual:
case Active, Suspended, Closed
Status.values.map(_.toString) // Array(Active, Suspended, Closed)
Status.valueOf("Closed") // Status.Closed
Status.Active.ordinal // 0
// Parameterised enum is a full ADT
enum Shape:
case Circle(r: Double)
case Rect(w: Double, h: Double)
def area: Double = this match
case Circle(r) => math.Pi * r * r
case Rect(w, h) => w * h
Key Points
- enum replaces both sealed hierarchies and the old scala.Enumeration
- values, valueOf and ordinal are generated for parameterless enums
- Parameterised cases are case classes, so matches stay exhaustivity-checked
- Java enum interop requires extends java.lang.Enum explicitly
Q13What replaced the implicit keyword in Scala 3, and how do given and using work?
BasicScala 3
Answer
Scala 2 overloaded one keyword, implicit, for four unrelated jobs: declaring a term the compiler can supply automatically, declaring a parameter list to be filled automatically, defining an implicit conversion, and defining an implicit class for extension methods. Scala 3 split those into four distinct constructs. given defines a term available for automatic synthesis. using marks a parameter list the compiler fills from givens in scope. extension defines extension methods directly, with no wrapper class. And implicit conversions now require an explicit given Conversion[A, B] instance plus import scala.language.implicitConversions, which stops a stray def toX conversion from silently changing what your code means.
To ask for an instance explicitly you call summon[Ordering[Money]], which replaces implicitly. You can name a given (given intOrd: Ordering[Int] = ...) or leave it anonymous, in which case the compiler generates a name; naming it makes error messages and ambiguity resolution far easier to read, so name anything you might need to disambiguate. Imports also changed: import foo.* brings normal members but not givens, and you need import foo.given, or import foo.{given Ordering[?]} to be selective.
That is a deliberate friction point so that adding a wildcard import cannot quietly change instance resolution. Scala 2 implicits still compile in Scala 3 for cross-building, and the -source:3.0-migration flag with scalafix rewrites the majority of a codebase mechanically, which is how most Indian teams did their 2.13 to 3 move.
trait Show[A]:
def show(a: A): String
object Show:
given Show[Int] with
def show(a: Int): String = a.toString
given listShow[A](using s: Show[A]): Show[List[A]] with
def show(as: List[A]): String = as.map(s.show).mkString("[", ", ", "]")
def render[A](a: A)(using s: Show[A]): String = s.show(a)
render(List(1, 2, 3)) // "[1, 2, 3]"
summon[Show[Int]].show(7) // explicit lookup, replaces implicitly
// Conversions are now opt-in and explicit
import scala.language.implicitConversions
given Conversion[String, Int] = _.length
val n: Int = "hello" // 5
Key Points
- given declares instances, using declares the parameter list that receives them
- summon replaces implicitly; extension replaces implicit class
- Conversions need given Conversion plus import scala.language.implicitConversions
- import foo.* does not import givens; use import foo.given
Q14What is a by-name parameter and how is it different from a by-value parameter or a Function0?
BasicLanguage Fundamentals
Answer
A by-value parameter (x: Int) is evaluated once at the call site before the method body starts. A by-name parameter (x: => Int) is not evaluated at the call site at all; the compiler wraps the argument expression in a thunk and evaluates it every single time the parameter is referenced inside the body. A Function0 parameter (x: () => Int) is similar in effect but explicit: the caller writes () => expr and the callee writes x() to force it.
The difference between the last two is purely syntactic sugar at the call site, and that sugar is what makes by-name so useful for building control structures: getOrElse, orElse, Try, assert, require and every logging API takes by-name arguments so the expensive expression is skipped when it is not needed. The trap is that by-name means re-evaluation, not caching. If the body references the parameter three times, the argument expression runs three times, so a by-name parameter that performs IO or increments a counter will surprise you.
The idiomatic fix is a lazy val inside the method: lazy val v = x captures the first evaluation and reuses it. The second trap is capture cost: because the thunk is an object, a by-name parameter in a hot inner loop allocates on every call unless the JIT can inline and eliminate it, so in performance-sensitive numeric code you sometimes measure a difference. Scala 3 also allows by-name parameters in given clauses, which is how recursive given instances for structures like Cats' Defer are built without stack-overflowing at instance construction.
def byValue(x: Long): Unit = { println(x); println(x) } // one call
def byName(x: => Long): Unit = { println(x); println(x) } // two calls
def func0(x: () => Long): Unit = { println(x()); println(x()) }
var counter = 0L
def tick(): Long = { counter += 1; counter }
byValue(tick()) // 1, 1
byName(tick()) // 2, 3 <- evaluated twice
func0(() => tick())// 4, 5
// Cache a by-name argument you need more than once
def retryOnce[A](op: => A): A =
try op catch case scala.util.control.NonFatal(_) => op
def logIfDebug(msg: => String): Unit =
if debugEnabled then println(msg) // msg never built when off
Q15What is the difference between a method and a function value, and what is eta-expansion?
BasicLanguage Fundamentals
Answer
A method defined with def is a JVM method: it belongs to a class or object, it can have multiple parameter lists, type parameters, default arguments, implicit or using clauses, and by-name parameters. A function value is an object, an instance of scala.FunctionN, with a single apply method; it can be stored in a val, passed around, and returned. Methods are not values, so converting one into a function value is a real transformation called eta-expansion.
In Scala 2 you triggered it explicitly with a trailing underscore (val f = double _) except in positions where the expected type was already a function, where it happened automatically. Scala 3 made eta-expansion automatic everywhere a function type is expected or when a method is used as a value, so val f = double just works and the trailing underscore is deprecated. This matters more than it sounds.
Eta-expansion allocates a lambda object, so passing a method reference in a tight loop is not free, though the JIT usually handles it. It also loses things methods have and functions do not: default arguments, named arguments, by-name parameter semantics and overload resolution all disappear once a method becomes a Function1, which is why an overloaded method used as a value needs a type ascription to tell the compiler which overload you meant. Multiple parameter lists are another common interview point: eta-expanding only the first list gives you a partially applied function, which is the mechanism behind currying and behind the way using clauses stay out of the resulting function type.
def double(x: Int): Int = x * 2
def add(a: Int)(b: Int): Int = a + b
val f: Int => Int = double // Scala 3: automatic eta-expansion
List(1, 2, 3).map(double) // method reference, expanded for you
val addFive = add(5) // partial application: Int => Int
addFive(3) // 8
// Overloads need an ascription to pick one
def show(x: Int): String = s"int $x"
def show(x: String): String = s"str $x"
val g: Int => String = show // ok: expected type disambiguates
// Function values are objects with an apply method
val h = (x: Int) => x + 1
h.apply(1) == h(1) // true
Key Points
- Methods are JVM methods; function values are FunctionN objects
- Eta-expansion is automatic in Scala 3, the trailing underscore is deprecated
- Default and named arguments do not survive eta-expansion
- Eta-expanding one parameter list of a curried method gives partial application
Q16Explain variance annotations +A and -A, and why Function1 is Function1[-T, +R].
BasicOOP and Types
Answer
Variance says how subtyping of a type parameter relates to subtyping of the container. Covariance, written +A, means that if Dog is a subtype of Animal then Box[Dog] is a subtype of Box[Animal]. Contravariance, written -A, reverses that.
Invariance, the default with no annotation, means no relationship at all. The compiler enforces a soundness rule: a covariant parameter can only appear in output positions (return types) and a contravariant parameter only in input positions (parameter types), otherwise you could write code that type checks and then fails at runtime. That is exactly why immutable List[+A] can be covariant while mutable Array[A] must be invariant: if arrays were covariant you could assign an Array[Dog] to an Array[Animal] and store a Cat in it, which is the ArrayStoreException hole Java actually has.
Function1[-T, +R] is the canonical example. A function that accepts any Animal is usable wherever a function accepting a Dog is expected, because it handles more inputs, so the parameter type is contravariant. A function returning a Dog is usable wherever a function returning an Animal is expected, because its result is more specific, so the return type is covariant.
When you need a covariant parameter in an input position, the standard escape hatch is a lower bound on the method: List's prepend is defined as def ::[B >: A](x: B): List[B], which widens the element type instead of breaking soundness. Interviewers ask this because getting it wrong in a library API forces every downstream caller to add casts.
class Animal; class Dog extends Animal; class Cat extends Animal
// Covariant: read-only producer
trait Producer[+A] { def get: A }
val pd: Producer[Dog] = new Producer[Dog] { def get = new Dog }
val pa: Producer[Animal] = pd // ok
// Contravariant: write-only consumer
trait Consumer[-A] { def consume(a: A): Unit }
val ca: Consumer[Animal] = (a: Animal) => println(a)
val cd: Consumer[Dog] = ca // ok
// Function1[-T, +R] combines both
val fn: Dog => Animal = (a: Animal) => new Dog // ok
// Lower bound widens instead of breaking soundness
val dogs: List[Dog] = List(new Dog)
val mixed: List[Animal] = new Cat :: dogs // uses ::[B >: A]
Q17Walk through Scala's type hierarchy: Any, AnyVal, AnyRef, Null, Nothing and Unit.
BasicOOP and Types
Answer
Any is the top type, the supertype of everything, and it declares only equals, hashCode, toString, isInstanceOf and asInstanceOf. Below it the hierarchy splits. AnyVal covers the nine value classes: Byte, Short, Int, Long, Float, Double, Char, Boolean and Unit, which map to JVM primitives when they can and get boxed to their java.lang wrappers when they cannot, for example inside a generic collection.
AnyRef is an alias for java.lang.Object and covers every reference type. Null is a subtype of every AnyRef but not of AnyVal, which is why val x: Int = null does not compile while val s: String = null does. Nothing is the bottom type, a subtype of everything including AnyVal, and it has no instances at all; an expression of type Nothing cannot return normally, so throw expressions and infinite loops are typed Nothing, and Nil is List[Nothing] while None is Option[Nothing].
Unit has exactly one value, (), and is the type of expressions evaluated only for side effects. Two practical consequences show up in interviews. First, when type inference finds a least upper bound it can silently produce Any: if(x) 1 else "a" infers Any, and then a downstream method that expects Int fails with a confusing error, which is why explicit return types on public methods are a house rule in most Scala teams.
Second, Nothing appearing in an inferred type is almost always a bug signal, for example an empty List whose element type never got fixed. Also remember that Scala 3 adds Matchable between Any and its subtypes, so pattern matching on a bare Any now needs a Matchable bound under -Ysafe-init style strictness.
val a: Any = 42 // Int widens to Any
val s: String = null // Null <: AnyRef
// val i: Int = null // does not compile: Null is not <: AnyVal
def fail(msg: String): Nothing = throw new IllegalStateException(msg)
val x: Int = if false then 1 else fail("boom") // Nothing <: Int
val empty = List.empty // List[Nothing]
val nums: List[Int] = List.empty[Int] // fix the parameter explicitly
// Inference landing on Any is a common silent bug
val mixed = if true then 1 else "one" // inferred Any
val u: Unit = println("side effect") // Unit has one value: ()
Key Points
- Any -> AnyVal (primitives) and AnyRef (java.lang.Object)
- Null is a subtype of AnyRef only; Nothing is a subtype of everything
- throw and infinite loops are typed Nothing
- Inferred Any or Nothing in your types is almost always a mistake
Q18How is an sbt build structured, and what does %% mean compared to %?
BasicBuild Tooling
Answer
sbt reads build.sbt at the project root, project/build.properties for the sbt version itself, and project/plugins.sbt for plugins. Sources live under src/main/scala and src/test/scala, and settings are expressed as key assignments on a project: scalaVersion, libraryDependencies, scalacOptions, and scoped keys such as Test / fork := true or Compile / run / javaOptions. The single most-asked syntax question is the difference between % and %%.
Because Scala is only binary compatible within a minor line, every library is published with the Scala version baked into its artifact name, for example cats-core_3 or cats-core_2.13. Writing "org.typelevel" %% "cats-core" % "2.x" tells sbt to append the suffix matching your scalaVersion, while a single % takes the artifact name literally and is what you use for pure Java dependencies such as the Postgres JDBC driver or Kafka clients. Getting this wrong produces an unresolved dependency error that lists an artifact name with the wrong suffix, which is the fastest way to diagnose it.
Other things worth knowing for a screen: sbt runs in a persistent JVM, so prefixing a command with ~ (as in ~test) re-runs it on file change; Test / fork := true runs tests in a separate JVM so system properties and JVM flags actually apply; dependencyTree or the evicted task shows conflicting versions, and sbt's default eviction rule picks the highest version rather than failing, which is a classic source of NoSuchMethodError at runtime. Scala CLI has largely replaced sbt for scripts, single-file examples and take-home tests, and Mill is the main alternative for large builds.
// build.sbt
ThisBuild / scalaVersion := "3.3.6"
ThisBuild / organization := "ai.goodspace"
lazy val root = (project in file("."))
.settings(
name := "ledger",
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-effect" % "3.6.1", // _3 suffix appended
"org.postgresql" % "postgresql" % "42.7.4", // plain Java artifact
"org.scalameta" %% "munit" % "1.1.0" % Test
),
scalacOptions ++= Seq("-deprecation", "-Wunused:all", "-Werror"),
Test / fork := true
)
// Useful commands
// sbt ~test re-run tests on every save
// sbt evicted show version conflicts sbt silently resolved
// sbt dependencyTree full transitive graph
Key Points
- %% appends the Scala binary version suffix; % does not
- Scoped keys look like Test / fork or Compile / run / javaOptions
- ~ prefixes any command for watch mode
- sbt resolves version conflicts by picking the highest; check evicted
Q19How do you implement a type class in Scala, and what is a context bound?
IntermediateType Classes
Answer
A type class is an interface you can retrofit onto a type you do not own. The pattern has three parts. First, a trait parameterised by the type it describes, for example trait Encoder[A] { def encode(a: A): String }.
Second, instances for concrete types, declared as given (Scala 3) or implicit val / implicit def (Scala 2), placed in the type class companion for the generic cases and in the data type's companion for its own instance, so implicit scope search finds them without imports. Third, a way to use them: either a method taking a using parameter, or syntax added by extension methods so callers write value.encode instead of Encoder[Foo].encode(value). A context bound is shorthand for the using parameter: def sort[A: Ordering](xs: List[A]) desugars to def sort[A](xs: List[A])(using Ordering[A]), and inside the body you retrieve the instance with summon[Ordering[A]].
Context bounds compose, so [A: Encoder: Ordering] asks for both. The reason interviewers care is that type classes are how Scala solves the problem Java solves with inheritance, and they behave differently: you can add an instance for java.time.Instant or for a third-party case class without touching those classes, instances are selected at compile time so there is no virtual dispatch cost after inlining, and you can define conditional instances such as given [A: Encoder]: Encoder[List[A]], which is how a JSON library encodes arbitrarily nested structures from a handful of primitives. The failure mode is ambiguity: two instances in scope for the same type produce a compile error, and orphan instances (defined outside both companions) are the usual cause because different files import different ones and behaviour changes by import.
trait Encoder[A]:
def encode(a: A): String
object Encoder:
def apply[A](using e: Encoder[A]): Encoder[A] = e
given Encoder[Int] = _.toString
given Encoder[String] = s => s"\"$s\""
// Conditional instance: derives List[A] from A
given [A](using e: Encoder[A]): Encoder[List[A]] =
as => as.map(e.encode).mkString("[", ",", "]")
extension [A](a: A)(using e: Encoder[A])
def encode: String = e.encode(a)
// Context bound: [A: Encoder] == (using Encoder[A])
def payload[A: Encoder](a: A): String = summon[Encoder[A]].encode(a)
List(1, 2, 3).encode // "[1,2,3]"
payload(List("a", "b")) // "[\"a\",\"b\"]"
Key Points
- Trait + instances + syntax is the whole pattern
- Put instances in companions so implicit scope finds them without imports
- [A: TC] is sugar for (using TC[A]); retrieve with summon
- Conditional givens build instances for nested types recursively
Q20In what order does the compiler search for a given or implicit, and how do you debug an ambiguity error?
IntermediateType Classes
Answer
Search happens in two phases. Phase one is lexical scope: givens and implicits visible without a prefix, meaning locals, enclosing definitions, inherited members, and anything imported. Phase two, used only when phase one finds nothing, is the implicit scope, which is the union of the companion objects of every type mentioned in the target type, plus the companions of their base classes and of their type arguments.
So for Encoder[List[User]] the compiler will look in object Encoder, object List, and object User. Within a phase, if more than one candidate matches, the compiler applies specificity rules: a more specific type wins, a given defined in a subclass wins over one in a superclass, and in Scala 3 an anonymous given with a more precise type wins. If nothing is strictly more specific you get ambiguous implicit values or ambiguous given instances, and the error names both candidates.
Debugging is mostly about visibility. In Scala 3, -Xprint:typer shows the elaborated tree with the chosen instance inserted, and -explain expands the error into a step-by-step trace of the failed search, which is by far the fastest tool for a diverging implicit expansion. Scala 2 has -Vimplicits (previously -Xlog-implicits) and the splain compiler plugin for readable traces.
Practical fixes: name your givens so you can import or shadow one specifically, move an ambiguous instance out of lexical scope into a lower-priority trait that the companion extends (the classic LowPriorityImplicits pattern), or pass the instance explicitly at the call site, which always wins over search. If compile times blow up rather than errors appearing, the cause is usually a recursive given with a large search space, and -Ximplicit-search-limit or restructuring the instance to be non-recursive is the answer.
// Low-priority fallback pattern to break ambiguity
trait LowPriority:
given fallback[A]: Show[A] = a => a.toString
object Show extends LowPriority:
given Show[Int] = i => s"int:$i" // wins over fallback for Int
trait Show[A]:
def show(a: A): String
// Explicit argument always beats search
def render[A](a: A)(using s: Show[A]) = s.show(a)
render(5)(using (i: Int) => s"custom:$i")
// Debugging flags
// Scala 3: scalacOptions += "-explain" (expanded search trace)
// Scala 3: scalacOptions += "-Xprint:typer" (see the inserted instance)
// Scala 2: scalacOptions += "-Vimplicits"
Key Points
- Lexical scope first, then implicit scope (companions of all types involved)
- Ties broken by specificity; equal candidates produce an ambiguity error
- -explain in Scala 3 and -Vimplicits in Scala 2 print the search trace
- LowPriorityImplicits inheritance is the standard tie-breaking trick
Q21How do Scala 3 extension methods differ from Scala 2 implicit classes and value classes?
IntermediateScala 3
Answer
In Scala 2 you added a method to a type you did not own by writing an implicit class: a one-argument class whose constructor parameter is the target type, plus your new methods. Every call site allocated a wrapper instance unless you also made it extend AnyVal, turning it into a value class so the compiler could erase the wrapper and call a static method instead. That pattern worked but was fragile: value classes lose their unboxed form the moment the instance is used as a type argument, stored in an Array, matched against a pattern, or assigned to a supertype, so the allocation you were avoiding often came back silently.
Scala 3's extension keyword removes the wrapper entirely. You write extension (s: String) def slugify: String = ..., and the compiler generates a plain static-like method with no intermediate object, so there is no allocation to worry about in the first place. Extensions can be generic, can take their own using clauses, can be grouped so several methods share one target, and can be defined inside a given so a type class provides its syntax alongside its instance.
Two details interviewers probe. First, resolution: an extension method is only applicable if it is in lexical scope or in the implicit scope of the receiver type, which means placing extensions in a type class companion makes them available wherever the instance is. Second, collisions: if a real member with the same name exists on the type, the member wins and your extension is silently never called, which is a genuinely annoying bug when a library later adds a method with your name. Opaque types plus extensions are the modern replacement for value classes.
// Scala 2 style
implicit class RichString(val s: String) extends AnyVal:
def slug: String = s.trim.toLowerCase.replaceAll("\\s+", "-")
// Scala 3 style: no wrapper class, no allocation
extension (s: String)
def slug: String = s.trim.toLowerCase.replaceAll("\\s+", "-")
def truncate(n: Int): String = if s.length <= n then s else s.take(n) + "..."
" Senior Scala Engineer ".slug // "senior-scala-engineer"
// Generic extension with its own using clause
extension [A](xs: List[A])(using o: Ordering[A])
def top(n: Int): List[A] = xs.sorted(o.reverse).take(n)
List(3, 9, 1).top(2) // List(9, 3)
// Real members shadow extensions: this calls String#length, not yours
extension (s: String) def length: Int = 999
"abc".length // 3
Q22How does ExecutionContext.global work, and what causes Future thread starvation in production?
IntermediateConcurrency
Answer
scala.concurrent.ExecutionContext.global is a ForkJoinPool sized by default to the number of available processors, so on an eight-core pod it has roughly eight worker threads. Every Future body, every map, flatMap and onComplete callback is submitted to that pool as a task. This is fine as long as every task is CPU-bound and short.
Starvation happens when a task blocks: a JDBC call, an Await.result, a Thread.sleep, a synchronous HTTP client, or a lock. The blocked worker still occupies a pool thread, so with eight threads and nine concurrent JDBC calls, the ninth waits behind them, and once callbacks queue behind blocked workers your service stops making progress while CPU sits near zero. In a container this looks like rising p99 with flat CPU, which is the exact symptom interviewers describe when they ask this question.
There are three correct answers. Use a separate, appropriately sized ExecutionContext for blocking work: a fixed thread pool sized to your connection pool, wired through ExecutionContext.fromExecutorService, and pass it explicitly rather than importing global. Wrap unavoidable blocking in scala.concurrent.blocking { ... }, which signals the ForkJoinPool to spin up a compensating thread; note this only works on ForkJoinPool-backed contexts and it grows the pool, so it is a mitigation, not a design.
Or move off Future entirely to Cats Effect or ZIO, whose runtimes separate a compute pool from a blocking pool and give you IO.blocking / ZIO.attemptBlocking as first-class operations. Also remember that Future is eager: constructing one starts it immediately, so any Future you build inside a for-comprehension runs sequentially, and separating construction from composition is the cheapest latency fix in most Scala codebases.
import scala.concurrent.{ExecutionContext, Future, blocking}
import java.util.concurrent.Executors
// Dedicated pool for blocking JDBC, sized to the connection pool
val jdbcEc: ExecutionContext = ExecutionContext.fromExecutorService(
Executors.newFixedThreadPool(20, r =>
val t = new Thread(r, "jdbc-pool"); t.setDaemon(true); t)
)
def loadUser(id: Long): Future[User] =
Future(blocking(jdbc.query(id)))(jdbcEc) // explicit EC, not global
// Sequential by accident: each Future starts when the previous completes
for { a <- loadUser(1); b <- loadUser(2) } yield (a, b)
// Parallel: construct first, then compose
val fa = loadUser(1); val fb = loadUser(2)
for { a <- fa; b <- fb } yield (a, b)
Key Points
- global is a ForkJoinPool sized to available processors
- Blocking a worker thread starves the pool; symptom is high p99 with low CPU
- Use a dedicated fixed pool for JDBC and other blocking IO
- scala.concurrent.blocking only helps on ForkJoinPool-backed contexts
- Future is eager, so build it before the for-comprehension to get parallelism
Q23A Scala service freezes under load with all threads in Await.result. Diagnose and fix it.
IntermediateConcurrency
Answer
Take a thread dump first with jstack or jcmd Thread.print against the running pid, or read it out of your APM. The signature is a set of threads named scala-execution-context-global-N parked in Await.result, and behind them a queue that never drains. This is classic pool self-deadlock: a task running on the pool blocks waiting for another task that can only run on the same pool, and once every worker is blocked nothing can complete.
It is not a livelock and it does not resolve on its own; the service needs a restart, which is why teams see it as a periodic mystery restart in Kubernetes rather than as a code bug. The immediate fix is to stop blocking. Await.result belongs in exactly two places: the last line of main, and tests.
Everywhere else, return the Future and let the framework complete the response asynchronously, because Play, Akka HTTP, http4s and Pekko HTTP all accept a Future or effect type as a handler result. If a synchronous API forces your hand, run the blocking part on a distinct ExecutionContext so the two pools cannot deadlock each other, and always pass a finite timeout to Await.result rather than Duration.Inf, so the failure is a TimeoutException you can alert on instead of a hang you discover from a dashboard. Longer term, three preventive measures matter: name your thread pools so dumps are readable, set the pool size deliberately rather than inheriting the core count, and add a synthetic health check that actually exercises a database round trip, because a liveness probe that only checks the HTTP port stays green while every worker is parked.
import scala.concurrent.duration.*
import scala.concurrent.Await
// Deadlock: the inner Future needs a worker that the outer Await is holding
Future {
Await.result(Future(compute()), Duration.Inf) // same global pool
}
// Fix 1: never block, return the Future
def handler(id: Long): Future[Response] =
service.load(id).map(Response.ok)
// Fix 2: if you must block, bound it and isolate the pool
Await.result(service.load(1)(jdbcEc), 3.seconds) // TimeoutException, not a hang
// Diagnose in production
// jcmd <pid> Thread.print | grep -A5 'scala-execution-context'
// look for WAITING on scala.concurrent.impl.Promise$DefaultPromise
Q24How does Cats Effect IO differ from Future, and what do fibers give you?
IntermediateEffect Systems
Answer
Future is eager and memoised: the moment you construct one, the work is submitted to an ExecutionContext, and the result is cached, so a Future value is not a description of work, it is work already happening. That breaks referential transparency, which means you cannot refactor by extracting a val without changing behaviour, you cannot retry a Future (retrying re-reads the same cached result), and you cannot express a timeout that actually cancels the underlying work. IO in Cats Effect 3 is a lazy, immutable description of a computation.
Nothing runs until the runtime interprets it at the edge, usually via IOApp, so an IO value can be stored, passed, retried, timed out, raced and combined freely. That laziness is the whole point: retry, timeout, race, parTraverse, resource acquisition with guaranteed release, and cancellation all become ordinary combinators rather than bespoke machinery. Fibers are the concurrency unit.
A fiber is a lightweight, cooperatively scheduled green thread multiplexed onto a small compute pool, so hundreds of thousands of concurrent fibers are normal where the same number of JVM threads would be impossible. Fibers are cancellable at well-defined points, and cancellation propagates through the structure of your program, which is what makes IO.race and IO.timeout genuinely release resources instead of leaking them the way a Future timeout does. Cats Effect 3 also splits its thread pools properly: a work-stealing compute pool sized to cores, and an unbounded blocking pool reached through IO.blocking, so the starvation problem that plagues Future is handled by the runtime. Resource[IO, A] gives bracket semantics for connection pools and file handles with release guaranteed even under cancellation.
import cats.effect.{IO, IOApp, Resource}
import cats.syntax.all.*
import scala.concurrent.duration.*
val work: IO[Int] = IO.println("running") *> IO.pure(42) // nothing has run yet
val program: IO[Unit] =
for
a <- work.timeout(2.seconds) // real cancellation
b <- work.handleErrorWith(_ => work) // safe to re-run: it is a value
xs <- List(1, 2, 3).parTraverse(i => IO(i * 2)) // concurrent fibers
_ <- IO.race(IO.sleep(1.second), work) // loser is cancelled
_ <- IO.blocking(jdbc.query(1)) // runs on the blocking pool
yield ()
val db: Resource[IO, Connection] =
Resource.make(IO.blocking(open()))(c => IO.blocking(c.close()))
object Main extends IOApp.Simple:
def run: IO[Unit] = db.use(_ => program)
Key Points
- Future is eager and memoised; IO is a lazy, referentially transparent description
- Retry, timeout and race only work correctly on a lazy effect type
- Fibers are cheap green threads with structured cancellation
- IO.blocking routes to a separate unbounded pool, avoiding compute starvation
- Resource guarantees release even when a fiber is cancelled
Q25What do the three type parameters of ZIO[R, E, A] mean, and what problem does ZLayer solve?
IntermediateEffect Systems
Answer
ZIO[R, E, A] is a description of a computation that needs an environment R, may fail with a typed error E, and succeeds with an A. Think of it as R => Either[E, A] wrapped in an effect. The environment channel is what distinguishes ZIO from Cats Effect IO: instead of threading a config object or a repository trait through every constructor, you declare the requirement in the type, and the compiler tracks it until you provide it at the edge.
Common aliases keep signatures readable: Task[A] is ZIO[Any, Throwable, A], UIO[A] is ZIO[Any, Nothing, A] and cannot fail, IO[E, A] is ZIO[Any, E, A], and RIO[R, A] is ZIO[R, Throwable, A]. The typed error channel is the part interviewers push on, because it changes how you write code: a UIO[A] is statically guaranteed not to fail, orDie converts a typed error into a defect, and ZIO separates failures (expected, in E) from defects (bugs, unrecoverable) from fiber interruption, with sandbox and Cause giving you the full picture including suppressed errors. ZLayer is the dependency injection story.
A ZLayer[RIn, E, ROut] is a recipe for building ROut from RIn that can itself be effectful and resource-safe, so a database layer opens a connection pool on acquire and closes it on shutdown. Layers compose horizontally with ++ and vertically with >>>, ZLayer.make wires a graph automatically by type, and because construction happens at compile time in terms of types you get a compile error naming the missing service rather than a runtime null. In practice teams choose ZIO for the batteries-included ecosystem and Cats Effect for the smaller, more compositional core.
import zio.*
trait UserRepo:
def find(id: Long): IO[RepoError, User]
sealed trait RepoError
case object NotFound extends RepoError
object UserRepo:
val live: ZLayer[Connection, Nothing, UserRepo] =
ZLayer.fromFunction((c: Connection) => new UserRepo { ... })
val conn: ZLayer[Any, Throwable, Connection] =
ZLayer.scoped(ZIO.acquireRelease(ZIO.attempt(open()))(c => ZIO.succeed(c.close())))
// R is tracked in the type until you provide it
val logic: ZIO[UserRepo, RepoError, String] =
ZIO.serviceWithZIO[UserRepo](_.find(1L)).map(_.name)
object Main extends ZIOAppDefault:
def run = logic.provide(conn, UserRepo.live) // missing layer = compile error
Key Points
- R = required environment, E = typed error, A = success value
- Task, UIO, RIO and IO are aliases that fix one or more channels
- ZIO separates failures (E), defects, and interruption; Cause exposes all three
- ZLayer is resource-safe, composable DI checked at compile time
Q26Why does LazyList leak memory, and when should you use a View instead?
IntermediateCollections
Answer
LazyList (which replaced the deprecated Stream in 2.13) is lazy in both head and tail and, critically, memoises every element it has produced. That memoisation is the leak. If you hold a reference to the head of a LazyList and then traverse a million elements, all million stay reachable through the head and none can be collected, so a pipeline that looks like a stream is actually building a list in memory.
The classic production incident is val lines = LazyList.from(source) held in a val at class level and then consumed repeatedly; heap grows until the executor or pod is OOM-killed, and the heap dump shows scala.collection.immutable.LazyList$Cons retaining everything. The rules that avoid it are: never bind a large LazyList to a val you keep, consume it in a single pass from a def so the head becomes unreachable as you walk, and prefer Iterator for genuinely one-shot streaming since Iterator does not memoise at all. View is the other tool and is often the right one.
Calling .view on a collection makes subsequent map, filter and flatMap lazy and non-memoising, so xs.view.map(f).filter(p).take(10).toList runs f only on the elements actually needed and allocates no intermediate collections. Without the view, each stage builds a full intermediate collection, which on a few million rows is the difference between a fast pipeline and a garbage collection storm. The trade-off is that a view re-evaluates on each traversal, so if the transformation is expensive and you iterate twice, materialise with toList. For real streaming with backpressure and resource safety, neither is the answer: use fs2.Stream or ZStream, which are lazy, memory-stable and cancellable.
// Leak: head is retained, so the whole materialised prefix is retained
val leaky: LazyList[Long] = LazyList.iterate(0L)(_ + 1)
leaky.take(50_000_000).sum // heap grows with every element
// Safe: a def means no stable reference to the head
def fresh: LazyList[Long] = LazyList.iterate(0L)(_ + 1)
fresh.take(50_000_000).sum // prefix becomes collectable as you go
// Iterator never memoises
Iterator.iterate(0L)(_ + 1).take(50_000_000).sum
// View: lazy, no intermediates, no memoisation
val rows = (1 to 5_000_000).toVector
rows.view.map(expensive).filter(_ > 0).take(10).toList // expensive runs ~10 times
rows.map(expensive).filter(_ > 0).take(10).toList // runs 5M times
Q27What changed in the 2.13 collections redesign, and how does it affect library code?
IntermediateCollections
Answer
The 2.13 redesign replaced CanBuildFrom with BuildFrom and reorganised the hierarchy around IterableOnce, IterableOps and factory objects. In 2.12, writing a generic method that returned the same collection type it received meant carrying an implicit CanBuildFrom[Repr, B, That] parameter, which produced signatures nobody could read and error messages nobody could decode. In 2.13, most transformation methods are defined on IterableOps with the concrete collection type supplied by the implementing class, so ordinary user code does not need any implicit at all, and BuildFrom is needed only when you genuinely write a collection-polymorphic library function.
Other user-visible changes: Traversable and TraversableOnce are gone, replaced by Iterable and IterableOnce; Stream is deprecated in favour of LazyList, which is lazy in the head as well as the tail; the .to method now takes a factory, so xs.to(Set) replaced xs.to[Set]; mutable and immutable ArraySeq were introduced as unboxed array wrappers; and view is non-memoising and much better behaved than the 2.12 version, which had known correctness bugs. There is also a set of new methods worth knowing because interviewers use them as a proxy for whether you have written 2.13 code recently: groupMap and groupMapReduce (a group-by plus map plus reduce in one pass, avoiding an intermediate Map of collections), tapEach, distinctBy, maxByOption and friends, and partitionMap for splitting a collection of Either. For library authors the migration cost was real, which is why scala-collection-compat exists: it back-ports the 2.13 API onto 2.12 so a single source tree can cross-build.
// 2.12: CanBuildFrom in every signature
// def evens[C[X] <: Seq[X]](xs: C[Int])(implicit cbf: CanBuildFrom[C[Int], Int, C[Int]]): C[Int]
// 2.13: BuildFrom, only when you really need collection polymorphism
import scala.collection.BuildFrom
def evens[C](xs: C)(using
it: C => Iterable[Int], bf: BuildFrom[C, Int, C]): C =
bf.fromSpecific(xs)(it(xs).filter(_ % 2 == 0))
val words = List("upi", "card", "cash", "neft")
words.to(Set) // replaces to[Set]
words.groupMap(_.length)(_.toUpperCase) // Map[Int, List[String]]
words.groupMapReduce(_.length)(_ => 1)(_ + _) // one pass, no intermediate
words.distinctBy(_.head)
List(Right(1), Left("e")).partitionMap(identity) // (List("e"), List(1))
Key Points
- CanBuildFrom replaced by BuildFrom, and most code no longer needs it
- Traversable gone; Iterable and IterableOnce are the roots
- Stream deprecated for LazyList; views no longer memoise
- groupMapReduce, partitionMap, distinctBy and tapEach are 2.13 additions
- scala-collection-compat lets one source tree cross-build 2.12 and 2.13
Q28How do you test Scala code with MUnit, ScalaTest and ScalaCheck, and when is a property test the right call?
IntermediateTesting
Answer
ScalaTest is the long-standing default and its main cost is choice: AnyFlatSpec, AnyFunSuite, AnyWordSpec, AnyFreeSpec and several more, each with its own DSL, plus matchers imported separately. Teams standardise on one style and enforce it, because a codebase with four styles is unreadable. MUnit is the modern lightweight alternative: one style, JUnit-compatible reporting, actual/expected diffs printed by default, and first-class async support through munit-cats-effect (test returning IO) or ScalaFutures-style helpers.
New Scala 3 projects mostly pick MUnit; existing Spark and Play codebases are usually ScalaTest, often with ScalaTest's Matchers and the scalatest-plus adapters. ScalaCheck sits on top of either and does property-based testing: instead of asserting on three hand-picked inputs, you declare a property that must hold for all inputs and ScalaCheck generates hundreds of cases, then shrinks any counterexample to the smallest failing input. That shrinking is the real value, because it turns a failure on a 4,000-character random string into a failure on the two-character case you can reason about.
Properties are the right tool for round trips (encode then decode equals identity), for algebraic laws (associativity of your merge function, idempotence of your dedup), and for parsers and serialisers. They are the wrong tool for behaviour that depends on specific business rules, where an example test states the intent more clearly. For integration tests, testcontainers-scala spins up real Postgres, Kafka or Redis in Docker per suite, which is far more trustworthy than a mocked repository, and Test / fork := true plus a shared container is the usual sbt configuration.
import munit.FunSuite
import org.scalacheck.Prop.forAll
class SlugSuite extends FunSuite:
test("slug lowercases and hyphenates") {
assertEquals("Senior Scala Dev".slug, "senior-scala-dev")
}
test("empty input") {
intercept[IllegalArgumentException]("".slug)
}
// Property test: round trip must hold for every generated input
class CodecSuite extends munit.ScalaCheckSuite:
property("encode then decode is identity") {
forAll { (u: User) => decode(encode(u)) == Right(u) }
}
property("merge is associative") {
forAll { (a: Cart, b: Cart, c: Cart) =>
merge(merge(a, b), c) == merge(a, merge(b, c))
}
}
Q29How do you build a Spark job jar with sbt-assembly, and why must Spark dependencies be Provided?
IntermediateBuild Tooling
Answer
spark-submit ships your application as a single jar to the cluster, and the cluster already has Spark, Hadoop and Scala on the classpath. If you bundle spark-core and spark-sql into your assembly, you get two copies of every Spark class, and the resulting failures are ugly and non-obvious: NoSuchMethodError, LinkageError, ClassCastException where a class appears incompatible with itself, or a job that silently uses a different Spark version than the cluster. Marking those dependencies % Provided removes them from the assembly and from the runtime classpath in sbt, though it also removes them from sbt run and Test, which is why builds usually add Compile / run := Defaults.runTask(...) or a separate configuration so local runs still work.
The second common problem is META-INF conflicts. Multiple jars ship their own META-INF/services entries, module-info.class files and signature files, and the default assembly strategy fails on duplicates. The standard assemblyMergeStrategy discards META-INF signatures, concatenates service loader files (which matters for JDBC drivers and Jackson modules, both of which use ServiceLoader), and falls through to first for the rest.
Discarding service files instead of concatenating them is a frequent cause of no suitable driver found errors at runtime. Third, shading: if your code and Spark disagree on a library version, notably Guava, Jackson or Netty, use assemblyShadeRules to relocate your copy into a private package so both can coexist. Finally, keep the assembly small; a 400 MB fat jar re-uploaded to every executor on every submit is a measurable part of job startup time on YARN and EMR.
// project/plugins.sbt
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1")
// build.sbt
lazy val sparkVersion = "3.5.3"
libraryDependencies ++= Seq(
"org.apache.spark" %% "spark-core" % sparkVersion % Provided,
"org.apache.spark" %% "spark-sql" % sparkVersion % Provided,
"io.circe" %% "circe-parser" % "0.14.10" // shipped in the jar
)
assembly / assemblyMergeStrategy := {
case PathList("META-INF", "services", _*) => MergeStrategy.concat
case PathList("META-INF", _*) => MergeStrategy.discard
case "module-info.class" => MergeStrategy.discard
case x => MergeStrategy.first
}
assembly / assemblyShadeRules := Seq(
ShadeRule.rename("com.google.common.**" -> "shaded.guava.@1").inAll
)
Key Points
- Spark, Hadoop and Scala are on the cluster classpath; mark them Provided
- Bundling them causes NoSuchMethodError and LinkageError at runtime
- Concat META-INF/services or ServiceLoader-based JDBC drivers stop resolving
- Shade Guava, Jackson or Netty when your version differs from Spark's
Q30What is binary compatibility in Scala, and how do crossScalaVersions and MiMa fit in?
IntermediateBuild Tooling
Answer
Scala guarantees binary compatibility only within a minor line, so a library compiled against 2.12 cannot be used from a 2.13 project and vice versa. That is why artifacts carry a suffix (cats-core_2.13, cats-core_3) and why sbt's %% operator exists. Scala 3 changed the picture in two useful ways: all 3.x releases are binary compatible with each other under the TASTy versioning scheme, so a library built with 3.3 works in a 3.7 project, and Scala 3 code can consume Scala 2.13 artifacts directly, with the reverse working only for libraries that avoid Scala 3-only features.
Practical consequence: the ecosystem no longer needs a flag-day migration, and most Indian teams moved to Scala 3 module by module rather than all at once. crossScalaVersions tells sbt which versions to build, and the + prefix runs a task across all of them, so sbt +test and sbt +publishSigned are the standard release commands for a library. When source differs between versions you either use scala-collection-compat, or put version-specific sources in src/main/scala-2.13 and src/main/scala-3, which sbt adds to the source path automatically. MiMa, the Migration Manager, is the tool that enforces the promise.
Added to the build as sbt-mima-plugin with mimaPreviousArtifacts set to your last release, it compares bytecode signatures and fails the build when you remove a method, change a signature, or make a class final, which are exactly the changes that produce NoSuchMethodError in a downstream project at runtime rather than at compile time. If you publish anything other people depend on, MiMa in CI is not optional.
// build.sbt
ThisBuild / crossScalaVersions := Seq("2.13.16", "3.3.6")
ThisBuild / scalaVersion := "3.3.6"
// Version-specific sources are picked up automatically from
// src/main/scala-2.13/ and src/main/scala-3/
// project/plugins.sbt
addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4")
// build.sbt
mimaPreviousArtifacts := Set(organization.value %% name.value % "1.4.0")
// Commands
// sbt +test build and test on every cross version
// sbt mimaReportBinaryIssues fail on breaking bytecode changes
// sbt +publishSigned release all cross builds
Key Points
- Binary compatibility holds within a minor line only, hence the _2.13 / _3 suffixes
- All Scala 3.x releases are binary compatible via TASTy
- Scala 3 can consume 2.13 artifacts; the reverse is limited
- crossScalaVersions plus the + prefix builds every target
- MiMa catches signature-breaking changes before your users hit NoSuchMethodError
Q31What causes org.apache.spark.SparkException: Task not serializable, and how do you fix it?
IntermediateSpark
Answer
Spark runs your lambdas on executors in a different JVM, so every closure is serialised on the driver and shipped with the task. Anything the closure captures travels with it, and if any captured object is not Serializable the driver throws SparkException: Task not serializable before a single row is read. The cause is almost never the value you were thinking about.
Referencing a field or a method of the enclosing class inside a lambda captures the outer instance, so the whole class has to serialise, and the serialization stack printed by Spark's SerializationDebugger names the offending link: look for the line beginning field (class: ..., name: $outer). If that class holds a SparkSession, a JDBC Connection, a Kafka producer or a non-serialisable logger, the job dies at submit time. There are four fixes, in order of preference.
Copy what you need into a local val before the lambda so only a primitive or a small immutable value is captured. Move the function into a top-level object, because Scala objects are singletons that do not drag an enclosing instance along. Mark heavyweight per-executor resources @transient lazy val so serialisation skips them and each executor rebuilds one on first use, which is the standard pattern for a database client or a model file.
Use sparkContext.broadcast for a large read-only lookup so it ships once per executor rather than once per task. Making the enclosing class extend Serializable is what most people try first and it is usually wrong: it either fails again on a nested field or silently ships megabytes with every task. Switching to Kryo with spark.serializer set to org.apache.spark.serializer.KryoSerializer shrinks the payload but does not make an unserialisable object serialisable, and spark.kryo.registrationRequired=true is worth enabling so unregistered classes fail loudly instead of writing full class names into every record.
class Enricher(spark: SparkSession) {
val cutoff = 100 // captured through `this`
@transient lazy val geo = new GeoLookup() // rebuilt per executor, never shipped
def run(ds: Dataset[Order]): Dataset[Order] = {
import spark.implicits._
val localCutoff = cutoff // copy: only an Int is captured
ds.filter(o => o.amount > localCutoff)
}
}
// Without the local copy:
// org.apache.spark.SparkException: Task not serializable
// Serialization stack:
// - object not serializable (class: Enricher, value: Enricher@1f2a)
// - field (class: Enricher$$anonfun$1, name: $outer)
// Large read-only lookup: broadcast it once per executor
val pins = spark.sparkContext.broadcast(loadPincodeMap())
ds.map(o => o.copy(zone = pins.value(o.pin)))
// spark-defaults.conf
// spark.serializer org.apache.spark.serializer.KryoSerializer
// spark.kryo.registrationRequired true
Key Points
- Closures are serialised on the driver; capturing a field captures the whole outer instance
- Read the Serialization stack in the exception and look for name: $outer
- Fixes: local val copy, top-level object, @transient lazy val, broadcast
- extends Serializable is the wrong first reflex
- Kryo shrinks payloads but cannot serialise a SparkSession or a JDBC connection
Q32When do you use RDD, DataFrame and Dataset in Spark, and what does an Encoder actually do?
IntermediateSpark
Answer
DataFrame is an alias for Dataset[Row]: untyped at compile time, completely visible to Catalyst, and the fastest option because every operation stays a column expression the optimiser can rewrite. Dataset[T] adds compile-time types through an Encoder[T], which import spark.implicits._ generates for case classes and which compiles to code that moves data between the JVM object and Tungsten's binary row format without going through Java serialisation. RDD is the low-level API with no schema, no Catalyst and no whole-stage code generation; it is the right answer only for genuinely unstructured input, custom partitioners, or operations the SQL API cannot express.
The point interviewers probe is that typed Dataset operations are not free. A ds.map(o => ...) or ds.filter(o => o.amount > 100) written with a Scala lambda is opaque to Catalyst: the physical plan gains a DeserializeToObject and a SerializeFromObject around your lambda, column pruning and predicate pushdown stop at that boundary, and whole-stage codegen splits. The same predicate written as a column expression pushes down into the Parquet reader and never materialises a JVM object at all.
So use typed Datasets at the edges for schema safety and column expressions in the middle for speed, and confirm with explain("formatted") rather than assuming. Encoders also constrain your model: case classes with Option for nullable columns work, java.time.Instant and BigDecimal are supported, but an arbitrary class needs Encoders.kryo, which stores an opaque binary blob and forfeits every optimisation and every SQL predicate. Mention versions when you answer this, because it dates you: Spark 3.x builds exist for Scala 2.12 and 2.13, Spark 4 requires Scala 2.13 and Java 17 or later, and there is still no Scala 3 build of Spark, so Spark work stays 2.13 even at shops writing Scala 3 services.
import org.apache.spark.sql.{Dataset, SparkSession}
import java.time.Instant
case class Order(id: Long, city: String, amount: BigDecimal, ts: Instant)
val spark = SparkSession.builder.appName("orders").getOrCreate()
import spark.implicits._
val ds: Dataset[Order] = spark.read.parquet("s3a://gs-data/orders").as[Order]
// Opaque to Catalyst: every row is deserialised into an Order object
ds.filter(o => o.amount > 100).count()
// Stays a column expression: pushed into the Parquet scan
ds.filter($"amount" > 100).count()
ds.filter($"amount" > 100).explain("formatted")
// PushedFilters: [IsNotNull(amount), GreaterThan(amount,100)]
// and no DeserializeToObject node in the physical plan
// Arbitrary types fall back to an opaque blob
implicit val enc: Encoder[LegacyBlob] = Encoders.kryo[LegacyBlob]
Key Points
- DataFrame = Dataset[Row]; Encoder[T] bridges JVM objects and Tungsten rows
- Lambda-based map/filter inserts DeserializeToObject and blocks pushdown
- Column expressions push predicates into the file scan
- Encoders.kryo works but loses schema, pruning and pushdown
- Spark 4 needs Scala 2.13 and Java 17+; no Scala 3 build of Spark exists
Q33How do you tune a Spark job that spends most of its time in shuffle, and what does AQE change?
IntermediateSpark
Answer
Read the Spark UI before changing anything: the SQL tab shows every exchange and the stage detail shows shuffle read, shuffle write and spill to memory and disk. Shuffle-bound jobs usually take one of three shapes. First, wrong partition count. spark.sql.shuffle.partitions defaults to 200 regardless of data size, so a 2 GB job creates 200 tasks that each do almost nothing while the scheduler pays per-task overhead, and a 2 TB job creates 200 tasks that all spill.
Adaptive Query Execution, enabled by default since Spark 3.2, fixes this at runtime by coalescing small post-shuffle partitions, and it also promotes a sort-merge join to a broadcast hash join once it sees the real size of one side, which is the biggest single win AQE delivers. Second, a join that should have been broadcast but was not. spark.sql.autoBroadcastJoinThreshold defaults to 10 MB and only fires when statistics exist, so run ANALYZE TABLE or add an explicit broadcast() hint for a dimension table you know is small. Third, skew.
One task runs for forty minutes while the other 199 finish in seconds, and the stage summary shows a max task duration two orders of magnitude above the median. spark.sql.adaptive.skewJoin.enabled splits oversized partitions automatically, but it only covers joins; for a skewed groupBy the answer is still salting the key or pre-aggregating. Beyond those: prefer reduceByKey-style aggregation over groupByKey, use coalesce instead of repartition when you only need fewer partitions and no full rebalance, cache only what is read more than once, and remember that FetchFailedException with a lost executor almost always means the container was killed for exceeding spark.executor.memoryOverhead rather than a network problem.
// spark-submit flags that actually move the needle
// --conf spark.sql.adaptive.enabled=true (default since 3.2)
// --conf spark.sql.adaptive.coalescePartitions.enabled=true
// --conf spark.sql.adaptive.skewJoin.enabled=true
// --conf spark.sql.autoBroadcastJoinThreshold=64m
// --conf spark.executor.memoryOverhead=2g
import org.apache.spark.sql.functions.{broadcast, rand, sum}
val enriched = facts.join(broadcast(dims), Seq("city_id"), "left")
// Salting a hot key so it spreads across 32 partitions
val totals = facts
.withColumn("salt", (rand() * 32).cast("int"))
.groupBy($"city_id", $"salt").agg(sum($"amount").as("part"))
.groupBy($"city_id").agg(sum($"part").as("total"))
enriched.explain("formatted")
// AQEShuffleRead ... coalesced / skewed confirms AQE actually engaged
Q34Akka moved to the BSL licence. What is Apache Pekko and what does migrating actually involve?
IntermediateAkka and Pekko
Answer
Lightbend relicensed Akka from Apache 2.0 to the Business Source License starting with Akka 2.7 in 2022. BSL is not an open source licence: it is free for development and testing and for organisations under a stated revenue threshold, and requires a paid subscription above it, with each version converting to Apache 2.0 after three years. Akka 2.6.x stays Apache 2.0 permanently, which is what made a fork legally possible.
Apache Pekko is that fork, donated to the ASF from the Akka 2.6.20 codebase and released as Pekko 1.0 in 2023, covering actors, streams, HTTP, gRPC and the connectors previously called Alpakka. Teams faced three choices: pay Lightbend, move to Pekko, or rewrite on Cats Effect with fs2 and http4s, or on ZIO. Migrating to Pekko is mechanical but not small.
Package names change from akka.* to org.apache.pekko.*, artifacts from com.typesafe.akka to org.apache.pekko, and every configuration key from akka. to pekko., including the ones buried in reference.conf overrides, dispatcher names, serialization bindings and logging setup. Pekko publishes a migration tool and there are scalafix rules that handle most of the source edits. The part that bites in production is clustering: Akka and Pekko nodes cannot form one cluster, because remoting, serializer identifiers and cluster message formats do not survive the rename.
That rules out a rolling upgrade of a clustered service, so you plan a full stop, or a blue-green cluster with traffic drained, or a bridge process that speaks both. Check the whole dependency tree too, since Play 2.9 runs on Akka while Play 3.0 runs on Pekko, and any library exposing akka.stream types in its public API has to move in the same commit.
// build.sbt: before
// "com.typesafe.akka" %% "akka-actor-typed" % "2.6.20"
// "com.typesafe.akka" %% "akka-stream" % "2.6.20"
// build.sbt: after
libraryDependencies ++= Seq(
"org.apache.pekko" %% "pekko-actor-typed" % "1.1.3",
"org.apache.pekko" %% "pekko-stream" % "1.1.3",
"org.apache.pekko" %% "pekko-http" % "1.1.0"
)
// Source changes are just the package prefix
import org.apache.pekko.actor.typed.ActorSystem
import org.apache.pekko.stream.scaladsl.Source
// application.conf keys are renamed too
// akka.actor.provider = cluster -> pekko.actor.provider = cluster
// akka.remote.artery.canonical.port -> pekko.remote.artery.canonical.port
Source(1 to 10).runFold(0)(_ + _) // same API, new package
Key Points
- Akka 2.7+ is BSL: free under a revenue threshold, paid above it
- Pekko is the ASF fork of Akka 2.6.20 and is Apache 2.0
- Migration renames packages, artifacts and every akka. config key
- Akka and Pekko nodes cannot share a cluster, so no rolling upgrade
- Play 2.9 is Akka, Play 3.0 is Pekko
Q35What do Functor, Applicative, Monad and Traverse buy you in Cats, and when is parTraverse the wrong call?
IntermediateEffect Systems
Answer
The hierarchy is really about how much power an operation needs. Functor gives map: change the value inside a context you cannot open. Applicative adds pure and the ability to combine independent effects, as in (fa, fb).mapN(f), where neither effect depends on the other's result.
Monad adds flatMap, which introduces dependency: the second effect is chosen from the first result, so it has to run afterwards. That difference is operational, not academic. Because applicative combinations are independent, the library is free to run them concurrently, while a monadic chain must be sequential, which is exactly why Cats gives you both traverse and parTraverse and why (fa, fb).parMapN beats a for-comprehension over the same two effects.
Traverse is the workhorse: xs.traverse(f) turns a List[A] plus an A => F[B] into an F[List[B]], and sequence is traverse(identity). It replaces the hand-rolled pattern of building a List[Either[E, B]] and then flipping it, and it short-circuits correctly, stopping at the first Left for Either and at the first raised error for IO. When you want every error instead of the first, traverse into ValidatedNel, whose Applicative accumulates rather than short-circuits. parTraverse is where teams get hurt.
It starts one fiber per element with no bound, so parTraverse over a 50,000-row batch that hits the database opens 50,000 concurrent requests, drains the connection pool and produces timeouts that look like a database fault until you read the code. Use parTraverseN(n) with n matching your pool size, or gate it with a Semaphore. Also remember traverse materialises the entire result: for large or genuinely streaming input use fs2.Stream with parEvalMap, which gives bounded concurrency and constant memory.
import cats.effect.IO
import cats.syntax.all.*
import cats.data.ValidatedNel
val ids = List(1L, 2L, 3L)
ids.traverse(fetchUser) // IO[List[User]], strictly sequential
ids.parTraverse(fetchUser) // one fiber per element: unbounded
ids.parTraverseN(8)(fetchUser) // bounded to the connection pool size
// Independent effects combined applicatively, run in parallel
(fetchUser(1L), fetchOrders(1L)).parMapN(Profile.apply)
// sequence is traverse(identity)
List(IO.pure(1), IO.pure(2)).sequence // IO[List[Int]]
// Either short-circuits; ValidatedNel accumulates every failure
def check(f: Form): ValidatedNel[String, Form] = ???
List(f1, f2, f3).traverse(check) // Invalid(NonEmptyList(all errors))
// Streaming input: bounded concurrency, constant memory
fs2.Stream.emits(ids).covary[IO].parEvalMap(8)(fetchUser).compile.toList
Q36How do you derive Circe codecs, and why does io.circe.generic.auto wreck compile times?
IntermediateLibraries
Answer
Circe encodes with Encoder[A] and decodes with Decoder[A], both ordinary type classes, so the only real question is where instances come from. Hand-writing them with forProductN compiles fastest and gives full control of field names, but nobody does that for a twenty-field case class. Automatic derivation, import io.circe.generic.auto.*, makes the compiler synthesise an instance wherever one is needed, and wherever is the problem: the instance is derived at every call site, so if thirty files encode a User the compiler builds the same generic representation thirty times, each one recursively deriving every nested type.
On a domain model of a few hundred case classes that alone can add minutes to a clean build and a lot of bytecode, and it hides mistakes, because deleting a hand-written codec still compiles by quietly falling back to auto. Semi-automatic derivation is what production codebases use: call deriveEncoder and deriveDecoder once inside the companion object, where implicit scope finds them with no import at all, so derivation happens once and every call site reuses the same instance. In Scala 3 the same thing is spelled with a derives clause backed by Mirror, which is cheaper still.
Configuration matters for real APIs: deriveConfiguredCodec with Configuration.default.withSnakeCaseMemberNames handles snake_case payloads, and withDiscriminator changes ADT encoding from the default wrapper object to a discriminator field, which is what most non-Scala clients expect. Two decoding details come up repeatedly. decode returns Either[Error, A] and stops at the first failure, while decodeAccumulating collects all of them, which is what a public API should return. And a missing field and an explicit null both decode to None for Option[A], so if PATCH semantics need to distinguish absent from cleared you need a three-state wrapper, not Option.
import io.circe.{Codec, Decoder, Encoder}
import io.circe.syntax.*
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
import io.circe.parser.decode
case class Address(city: String, pin: String)
case class User(id: Long, fullName: String, address: Option[Address])
object Address:
given Codec[Address] = Codec.from(deriveDecoder, deriveEncoder)
object User:
// derived once; implicit scope finds it everywhere with no import
given Encoder[User] = deriveEncoder
given Decoder[User] = deriveDecoder
User(1L, "Asha", Some(Address("Noida", "201301"))).asJson.noSpaces
decode[User]("""{"id":1,"fullName":"Asha"}""") // Right(User(1,Asha,None))
decode[User]("""{"id":"x"}""") // Left(DecodingFailure at .id)
// Scala 3 Mirror-based derivation: cheapest of the three
case class Page(total: Int, items: List[User]) derives Codec.AsObject
Key Points
- generic.auto re-derives at every call site and inflates compile time and bytecode
- semiauto in the companion derives once and needs no import at the use site
- Scala 3 derives Codec.AsObject uses Mirror and is cheaper again
- withDiscriminator changes ADT encoding away from the wrapper-object default
- decodeAccumulating returns every error; decode stops at the first
Q37What is tagless final, what does it actually buy you, and when is it over-engineering?
AdvancedArchitecture
Answer
Tagless final means writing your program against an abstract effect type F[_] plus the smallest set of capability constraints it needs, instead of committing to a concrete effect like IO. A repository becomes trait UserRepo[F[_]] { def find(id: Long): F[Option[User]] }, business logic becomes def register[F[_]: Monad](...)(using repo: UserRepo[F]), and only main picks F = IO. Three things follow.
The constraint list is a capability declaration: a function that asks only for Monad[F] provably cannot open a socket or read the clock, because it has no operation to do so, which makes the type signature a meaningful contract rather than decoration. Tests get cheap, since you can instantiate the same logic at F = Id or at a State monad and assert on the effect log without a runtime or a Docker container. And you can swap interpreters, which matters for libraries that must work under both Cats Effect and ZIO.
The costs are real and interviewers expect you to name them. Every signature grows an F[_] and a constraint list, which makes stack traces and compiler errors worse, since higher-kinded inference fails in ways that are hard to read. Newcomers to the codebase pay a tax on day one.
And in most services F is IO in production and IO in tests too, because you need real concurrency semantics to test anything interesting, so the abstraction never pays for itself. The mainstream 2026 position on Typelevel teams is pragmatic: use concrete IO in application code, define algebras as plain traits returning IO, and test with hand-written fakes. Reserve tagless final for libraries with more than one consumer runtime. Free monads are the older alternative and are now rare, because they add allocation for every step and give worse type inference for the same benefit.
import cats.Monad
import cats.syntax.all.*
import cats.effect.IO
trait UserRepo[F[_]]:
def find(id: Long): F[Option[User]]
def save(u: User): F[Unit]
trait Clock[F[_]]:
def now: F[java.time.Instant]
// Constraints declare exactly which capabilities the logic may use
def touch[F[_]: Monad](id: Long)(using repo: UserRepo[F], c: Clock[F]): F[Boolean] =
for
maybe <- repo.find(id)
ts <- c.now
done <- maybe.fold(false.pure[F])(u => repo.save(u.copy(seenAt = ts)).as(true))
yield done
// Production interpreter
given UserRepo[IO] = PostgresUserRepo
given Clock[IO] = IO.realTimeInstant.pure_
// Test interpreter: no runtime, no container
import cats.Id
given UserRepo[Id] = InMemoryRepo
given Clock[Id] = () => java.time.Instant.EPOCH
touch[Id](1L) // runs synchronously, returns Boolean
Q38How do opaque types work in Scala 3, and what can they not do?
AdvancedScala 3
Answer
An opaque type alias gives you a distinct type at compile time with zero representation at runtime. Writing opaque type UserId = Long inside an object means that within that object's scope UserId and Long are interchangeable, so you can implement operations directly on the underlying representation, while outside the scope UserId is an abstract type with no known relationship to Long. Passing an OrderId where a UserId is expected becomes a compile error, and after erasure both are just a long on the JVM: no wrapper object, no allocation, no boxing when stored in an Array or used as a type argument.
That last part is what makes opaque types strictly better than the Scala 2 value class they replace, because a value class extending AnyVal silently re-boxes the moment it is used generically, stored in an array, pattern matched, or upcast, which defeats the reason you wrote it. The idiomatic pattern is a companion-style object holding the opaque type, a smart constructor returning Either or Option for validation, an unsafe apply for trusted input, and extension methods for the operations you want to expose. Given instances (Ordering, circe Codec, doobie Meta) must be defined inside the defining scope, since only there does the compiler know the representation.
The limits are worth stating precisely because interviewers ask. You cannot pattern match on an opaque type as a distinct runtime type: case x: UserId does not compile in Scala 3 (and would be meaningless after erasure), so runtime type tests, overload resolution on the alias, and reflection-based frameworks all see the underlying type. There is no subtyping unless you declare a bound, opaque type Meters <: Double, which then leaks the underlying type into the API deliberately. And opaque types are file-scoped abstractions, so a Java caller sees only long.
object ids:
opaque type UserId = Long
opaque type OrderId = Long
object UserId:
def from(l: Long): Either[String, UserId] =
if l > 0 then Right(l) else Left(s"bad user id: $l")
def unsafe(l: Long): UserId = l
given Ordering[UserId] = Ordering.Long // must live in this scope
extension (id: UserId)
def value: Long = id
def masked: String = s"u***${id % 1000}"
import ids.*
def load(id: UserId): Unit = ()
val u = UserId.unsafe(42L)
load(u) // ok
// load(42L) // error: Found Long, Required UserId
// val bad: Long = u // error: no relationship outside the scope
u.value // 42, zero cost: erases to a plain long
// Not possible: opaque types have no runtime identity
// (u: Any) match { case _: UserId => ... } // does not compile
Key Points
- Distinct at compile time, erased to the underlying type at runtime
- No allocation even in Arrays and generic positions, unlike AnyVal value classes
- Given instances and extensions must be defined inside the defining scope
- No runtime type test, no pattern match on the alias, no reflection visibility
- Add a bound (opaque type Meters <: Double) only when you want the leak
Q39What do inline, transparent inline and scala.compiletime do, and when do you reach for quotes and splices?
AdvancedMetaprogramming
Answer
inline in Scala 3 is a guarantee, not a hint to the JIT: an inline def is expanded into the call site by the compiler during typing. That enables constant folding when parameters are also inline, dead branch elimination through inline if and inline match, and compile-time errors through scala.compiletime.error, so a misuse fails the build with your own message instead of throwing at runtime. transparent inline goes further by letting the expansion refine the declared result type: the method says it returns Any or a general type, but callers see the precise type of whatever the expansion produced. That is the mechanism behind type class derivation and behind libraries that turn a string literal into a structured type.
The scala.compiletime package supplies the supporting pieces: constValue to lift a singleton type into a value, erasedValue to pattern match on types without values, summonInline to resolve a given during expansion rather than at definition site, and summonAll to collect instances for every element of a tuple. Most derivation code never needs a macro at all, because inline plus Mirror covers it. Reach for quotes and splices, the '{ ... } and ${ ... } syntax with a given Quotes, when you need to inspect or build a syntax tree: reading a field's annotations, validating a literal such as a regex or a SQL string at compile time, or generating a codec that cannot be expressed with inline alone.
Macros must live in a separate compilation unit from their use, since the macro has to be compiled first. Practical failure modes: expansion is exponential when inline methods call each other, and the compiler stops with Maximal number of successive inlines exceeded, controlled by -Xmax-inlines with a default of 32; heavy inline code inflates bytecode and slows every downstream module; and Scala 3 macros share no source compatibility with Scala 2 def macros, which is why every macro-based library needed a rewrite.
import scala.compiletime.{constValue, error, summonInline, erasedValue}
// Compile-time validation with your own error message
inline def pin(inline s: String): String =
inline if s.length == 6 then s
else error("PIN code must be exactly 6 digits")
pin("201301") // fine
// pin("20130") // compile error: PIN code must be exactly 6 digits
// transparent inline refines the result type at the call site
transparent inline def zero(inline t: String): Any =
inline if t == "int" then 0 else ""
val z: Int = zero("int") // typed as Int, not Any
// Recursive inline over a tuple type, no macro needed
inline def labels[T <: Tuple]: List[String] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (h *: t) => constValue[h].toString :: labels[t]
// Deep inline recursion hits:
// error: Maximal number of successive inlines (32) exceeded
// scalacOptions += "-Xmax-inlines:64"
Q40How does a derives clause work with Mirror, and how does that compare with shapeless or Magnolia?
AdvancedMetaprogramming
Answer
In Scala 3 the compiler synthesises a Mirror for every case class, enum and sealed hierarchy whose children are all case classes or case objects. Mirror.ProductOf[A] exposes MirroredElemTypes, a tuple of the field types, MirroredElemLabels, a tuple of singleton string types for the field names, and fromProduct to rebuild an instance. Mirror.SumOf[A] exposes the same for the branches plus ordinal to identify which one you have.
Writing case class User(...) derives Show tells the compiler to call Show.derived[User], which you implement as an inline def taking a using Mirror.Of[A]. Inside it you use summonAll to collect an instance for every element type, constValue to read each label, and ordinary inline recursion over the tuple. Everything happens during typing, so there is no runtime reflection, no generic representation allocated per call, and no extra dependency.
Compare that with the older options. Shapeless 2 on Scala 2 built the same thing from HList and LabelledGeneric using implicit macros, which worked but produced infamous compile times and error messages that reported a failed implicit for a nested HList type nobody could read. Magnolia wrapped macros in a much friendlier API and still matters because it works on both Scala 2 and 3 with one source.
Mirror is the cheapest of the three when you are Scala 3 only. Two gotchas to name. Recursive data types diverge unless you defer the inner instance with summonInline inside the derived body rather than resolving it eagerly, and deep structures still hit the inline limit reported as Maximal number of successive inlines exceeded. And when one field type lacks an instance, the error usually points at the top-level derives clause rather than the field, so derive in small steps while developing.
import scala.deriving.Mirror
import scala.compiletime.{constValue, erasedValue, summonAll}
trait Show[A]:
def show(a: A): String
object Show:
given Show[Int] = _.toString
given Show[String] = identity
inline def labels[T <: Tuple]: List[String] =
inline erasedValue[T] match
case _: EmptyTuple => Nil
case _: (h *: t) => constValue[h].toString :: labels[t]
inline def derived[A](using m: Mirror.ProductOf[A]): Show[A] =
val instances = summonAll[Tuple.Map[m.MirroredElemTypes, Show]]
.productIterator.toList.asInstanceOf[List[Show[Any]]]
val names = labels[m.MirroredElemLabels]
(a: A) =>
val values = a.asInstanceOf[Product].productIterator.toList
names.zip(values.zip(instances))
.map { case (n, (v, s)) => s"$n=${s.show(v)}" }
.mkString("{", ", ", "}")
case class User(id: Int, name: String) derives Show
summon[Show[User]].show(User(1, "Asha")) // {id=1, name=Asha}
Key Points
- Mirror is compiler-generated for case classes, enums and sealed hierarchies
- MirroredElemTypes and MirroredElemLabels drive inline recursion over fields
- No runtime reflection and no generic representation allocated at runtime
- Magnolia still wins when one source tree must serve Scala 2 and 3
- Recursive types need summonInline, or derivation diverges
Q41What are match types in Scala 3, and where do they break down?
AdvancedScala 3
Answer
A match type computes a type by pattern matching on a type. Writing type Elem[X] = X match { case String => Char; case Array[t] => t; case Iterable[t] => t } gives you a function from types to types that the compiler reduces during typing, so Elem[List[Int]] is Int and Elem[String] is Char. They are not a curiosity: Tuple.Elem, Tuple.Concat, Tuple.Map and much of the tuple machinery in the standard library are match types, and they are what makes heterogeneous tuple operations type safe without shapeless.
The usual pattern is to pair a match type with an inline def whose value-level inline match mirrors the type-level cases, so the implementation and the computed result type stay in step. Where they break down is reduction. A match type only reduces when the scrutinee is concrete enough to select exactly one case; if you call your function with an abstract type parameter, the compiler cannot pick a branch and reports Match type reduction failed since selector X does not match any case, or leaves the type unreduced so nothing downstream type checks.
Recursive match types are bounded by a reduction depth limit, and exceeding it produces a cyclic or too-deep error rather than a hang. Scala 3.4 tightened match type checking for soundness, so code that compiled on 3.3 can fail on later releases with a reduction error, which is a real migration cost teams hit when moving off the LTS. The practical advice is proportionality: if an overload, a type class or a plain enum expresses the same API, use it. Match types earn their keep for tuple and record manipulation, unit or dimension tracking, and library APIs where the result type genuinely depends on an input type.
type Elem[X] = X match
case String => Char
case Array[t] => t
case Iterable[t] => t
summon[Elem[String] =:= Char]
summon[Elem[List[Int]] =:= Int]
// The value level mirrors the type level
inline def first[X](x: X): Elem[X] = inline x match
case s: String => s.charAt(0)
case a: Array[t] => a(0)
case i: Iterable[t] => i.head
first("scala") // 's': Char
first(List(1, 2, 3)) // 1: Int
// Standard library match types
summon[Tuple.Elem[(Int, String, Boolean), 1] =:= String]
type Opts[T <: Tuple] = Tuple.Map[T, Option]
// Abstract scrutinee: nothing to reduce
// def broken[X](x: X): Elem[X] = ???
// error: Match type reduction failed since selector X
// matches none of the cases
Q42Where does a Scala service actually spend heap, and how do you profile it in production?
AdvancedJVM and Performance
Answer
Scala's memory profile differs from Java's in predictable ways. Boxing is the first: List[Int] stores java.lang.Integer objects inside cons cells, so a million ints costs tens of megabytes instead of four, and the same applies to Vector[Double] and to any primitive used as a type argument. Second, intermediate collections: a chain of map, filter and map builds a full collection at every stage, which on a few million rows produces a garbage collection storm that shows up as high allocation rate and frequent young collections rather than as a leak.
Third, closures and tuples: every lambda passed to a method allocates unless the JIT can eliminate it, and map(x => (x.id, x.amount)) allocates a Tuple2 per element. The fixes are mundane: Array or ArraySeq for primitives, iterator or view for chains so nothing intermediate is materialised, a while loop in a genuinely hot inner method, and pre-sized builders. Profiling is where candidates separate. jcmd pid GC.heap_info gives you the live picture, jcmd pid GC.heap_dump plus Eclipse MAT's dominator tree tells you what is retained and by whom, and Java Flight Recorder started with -XX:StartFlightRecording captures allocation, GC and lock events with low enough overhead to leave on in production.
For allocation hot spots, async-profiler in alloc mode gives a flame graph that names the exact line. Two container-specific traps are worth raising. Size the heap with -XX:MaxRAMPercentage rather than a fixed -Xmx, since the JVM reads cgroup limits and a hardcoded -Xmx ignores a resized pod. And a pod OOM-killed while the heap graph looks flat is native memory, usually Netty direct buffers or Spark off-heap, which you diagnose with -XX:NativeMemoryTracking=summary and jcmd VM.native_memory, not with a heap dump.
// Boxing: same data, very different footprint
val boxed: List[Int] = (1 to 1_000_000).toList // Integer + cons cell per element
val unboxed: Array[Int] = (1 to 1_000_000).toArray // one contiguous int[]
// Three intermediate collections vs none
rows.map(parse).filter(_.valid).map(_.amount).sum
rows.iterator.map(parse).filter(_.valid).map(_.amount).sum
// Live diagnosis
// jcmd <pid> GC.heap_info
// jcmd <pid> GC.heap_dump /tmp/heap.hprof then open in Eclipse MAT
// jcmd <pid> VM.native_memory summary off-heap growth
// asprof -e alloc -d 60 -f alloc.html <pid> allocation flame graph
// Container-safe JVM flags
// -XX:MaxRAMPercentage=70
// -XX:+UseG1GC
// -XX:StartFlightRecording=duration=120s,filename=/tmp/svc.jfr
// -XX:NativeMemoryTracking=summary
Key Points
- Boxing in generic collections is the biggest avoidable Scala-specific cost
- Chained collection ops allocate a full intermediate per stage; use iterator or view
- GC.heap_dump plus MAT dominator tree answers what is retained and by whom
- JFR and async-profiler alloc mode find allocation hot spots by line
- A flat heap with an OOM-killed pod means native memory, not the heap
Q43What do JDK 21 virtual threads change for Scala, and how does that relate to fibers?
AdvancedConcurrency
Answer
Virtual threads, final in JDK 21, make blocking cheap. A virtual thread that blocks on IO unmounts from its carrier platform thread instead of holding it, so the thread-per-request model scales to hundreds of thousands of concurrent operations without any async plumbing. For plain Scala that is directly useful: back an ExecutionContext with Executors.newVirtualThreadPerTaskExecutor and the Future code that used to starve an eight-thread ForkJoinPool on JDBC calls stops starving, because there is no fixed pool to exhaust.
It does not make Future lazy, cancellable or referentially transparent, so the design problems with Future remain. Three caveats matter in interviews. Pinning: on JDK 21 a virtual thread blocked inside a synchronized block pins its carrier, and since plenty of JDBC drivers and older libraries synchronise internally, you can reintroduce starvation without noticing; JDK 24 removed that limitation for monitors, but until your runtime is there, run with the pinned-thread diagnostics enabled.
Second, virtual threads do nothing for CPU-bound work: the carrier pool is still sized to cores. Third, ThreadLocal-based context such as an SLF4J MDC becomes expensive when you have a million threads, which is what ScopedValue exists to replace. For Cats Effect and ZIO the picture is different, because their fibers already provide the same cheap concurrency in userspace, plus cancellation, structured supervision and a separate blocking pool that virtual threads do not give you.
The sensible combination is to keep fibers for your concurrency model and let the blocking pool run on virtual threads. JDK structured concurrency, the StructuredTaskScope API that binds child tasks to a lexical scope and cancels them together, has stayed a preview API across several releases, whereas the same guarantee is already production-ready in Scala through IO.race, parTraverse, Supervisor and Resource.
import java.util.concurrent.Executors
import scala.concurrent.{ExecutionContext, Future}
// Blocking work on virtual threads: no fixed pool left to starve
given ec: ExecutionContext =
ExecutionContext.fromExecutorService(
Executors.newVirtualThreadPerTaskExecutor())
def load(id: Long): Future[User] = Future(jdbc.query(id)) // blocking is fine now
Future.sequence((1L to 20_000L).toList.map(load))
// Cats Effect already has cheap cancellable fibers; keep them, and let
// the blocking pool sit on virtual threads
IO.blocking(jdbc.query(1L)) // separate pool, cancellable, structured
IO.race(work, IO.sleep(2.seconds)) // loser is actually cancelled
// Diagnose pinning before you trust the migration (JDK 21 and 22)
// -Djdk.tracePinnedThreads=full
// Thread[#42,VirtualThread] ... <== monitors:1 means a synchronized block
Q44A Scala module takes eight minutes to compile. How do you find out why and what usually fixes it?
AdvancedBuild Tooling
Answer
Measure before changing anything, because the intuitive culprits are rarely the real ones. On Scala 3, -Vprofile makes the compiler print where time went per run and which files were most expensive. On Scala 2, -Ystatistics:typer plus the Scala Center scalac-profiling plugin produces a flame graph of implicit search, which is usually the single largest line item.
Add sbt -Dsbt.task.timings=true to see which tasks dominate the build rather than the compile itself. Four causes account for most eight-minute modules. Implicit or given search, especially automatic type class derivation: io.circe.generic.auto re-derives a codec at every call site, and a domain model of a few hundred case classes can spend minutes doing the same work repeatedly.
Inline and macro expansion, which is transitive, so one heavily inlined helper slows every module that calls it, and deep recursion trips Maximal number of successive inlines exceeded. Very large files and very large case classes, since a fifty-field case class generates a lot of synthetic code. And Zinc invalidation: changing a widely inherited trait, a sealed hierarchy or anything a macro depends on invalidates the transitive closure, so a one-line edit rebuilds the module.
The fixes are structural more often than flag tweaks. Replace auto derivation with semiauto instances placed in companion objects so each codec is built once. Split one large module into several so Zinc invalidates less and sbt compiles them in parallel.
Keep macro and inline-heavy code in its own module. Give sbt real heap in .jvmopts, because a compiler JVM that is garbage collecting is not compiling. And keep the expensive linting flags on CI rather than in the inner loop, since -Wunused analysis is not free on every save.
// Scala 3: where did the compiler spend its time
scalacOptions += "-Vprofile"
// Scala 2: implicit search is usually the top line item
scalacOptions ++= Seq("-Ystatistics:typer", "-Vimplicits")
// Which sbt task, not just which file
// sbt -Dsbt.task.timings=true compile
// .jvmopts: a GC-thrashing compiler JVM looks like a slow codebase
// -Xmx4G
// -XX:+UseG1GC
// -Xss4m
// Structural fixes that beat any flag
lazy val macros = project // inline and macro code, compiled once
lazy val model = project // case classes plus semiauto codecs
lazy val api = project.dependsOn(model, macros)
// Recursive derivation blowing the inline budget
// error: Maximal number of successive inlines (32) exceeded
scalacOptions += "-Xmax-inlines:64"
Key Points
- -Vprofile on Scala 3, -Ystatistics:typer plus scalac-profiling on Scala 2
- Automatic codec derivation is the most common single cause
- Zinc invalidates the transitive closure of a changed trait or macro
- Split modules so invalidation is narrower and compilation parallelises
- Give sbt heap in .jvmopts before blaming the compiler
Q45How do you keep an fs2 pipeline memory-stable and at-least-once correct against Kafka?
AdvancedEffect Systems
Answer
fs2.Stream[F, A] is pull-based, so backpressure is structural rather than configured: nothing is produced until the downstream consumer pulls, which means a stream reading a 40 GB file and writing to Postgres runs in constant memory by default. It is also chunked internally, so per-element operations still process a Chunk at a time, and you control batching explicitly with chunkN or groupWithin, the latter emitting either when the batch fills or after a timeout, which is exactly what you want for bulk inserts. Resource safety comes from Stream.resource and bracket, so a connection or file handle is released when the stream finishes, fails or is cancelled.
Concurrency is bounded on purpose: parEvalMap(n) runs n effects concurrently while preserving order, parEvalMapUnordered(n) drops the ordering guarantee for throughput, and both are backpressured, unlike a bare parTraverse. For Kafka with fs2-kafka the correctness question is offsets. At-least-once means you commit only after the side effect has succeeded, so disable auto-commit, carry the CommittableOffset alongside each record, and pipe the result through commitBatchWithin, which batches commits by count and time.
The failure people ship to production is using parEvalMapUnordered ahead of the commit stage: offsets then complete out of order, a later offset can be committed while an earlier record is still failing, and after a rebalance those earlier messages are never redelivered, so the pipeline loses data silently under load and looks perfect in tests. The other classic is calling compile.toList on an unbounded source, which converts a constant-memory stream back into an unbounded buffer. Use compile.drain, compile.foldMonoid, or write to the sink inside the stream.
import cats.effect.IO
import fs2.kafka.*
import scala.concurrent.duration.*
val settings = ConsumerSettings[IO, String, String]
.withBootstrapServers("kafka:9092")
.withGroupId("orders-enricher")
.withAutoOffsetReset(AutoOffsetReset.Earliest)
.withEnableAutoCommit(false) // at-least-once needs manual commits
KafkaConsumer.stream(settings)
.subscribeTo("orders")
.records
.parEvalMap(8) { c => // bounded AND order preserving
handle(c.record).as(c.offset)
}
.through(commitBatchWithin(500, 15.seconds)) // commit after the effect
.compile
.drain
// Batch DB writes without buffering the whole stream
rows.groupWithin(1000, 2.seconds)
.evalMap(chunk => repo.insertMany(chunk.toList))
.compile.drain
// Silent data loss: unordered completion ahead of the commit stage
// .parEvalMapUnordered(8)(...).through(commitBatchWithin(500, 15.seconds))
Frequently Asked Questions
How much does a Scala developer earn in India in 2026?
Roughly ₹10-32 LPA, and the band is wider than it looks because two very different jobs share the label. Data engineers writing Spark in Scala at product and retail-tech companies typically sit in the ₹12-24 LPA range at three to seven years, while purely functional backend work on Cats Effect or ZIO, and quant and risk platform roles at investment banks with Bengaluru, Pune and Mumbai centres, run higher and can pass ₹35 LPA at senior and staff levels. The premium exists because supply is thin: far fewer Indian engineers write Scala than Java or Python, so companies that need it pay for it. What lifts an offer inside the band is rarely more language trivia. It is Spark tuning on real cluster sizes, streaming with Kafka, effect systems, and the ability to reason about JVM memory and concurrency under production load.
How long should I prepare for a Scala interview?
If you already write Scala daily, two to three weeks of focused revision is enough: collections performance, variance, implicit and given resolution, Future versus IO, and whichever of Spark, Akka or Pekko, or Cats Effect your target companies use. Coming from Java or Kotlin, plan three to four months, because the hard part is not syntax but the shift to expression-oriented, immutable design plus one library ecosystem. Coming from PySpark to Scala Spark is faster, usually six to eight weeks, since the DataFrame API transfers almost directly and what you add is the type system, sbt and the JVM. Build one real project rather than grinding exercises: a service on http4s or Play with a database and tests, or a Spark job that reads Parquet, joins, handles skew and is packaged with sbt-assembly. Interviewers ask what broke and how you found it, and only a real project gives you that answer.
Can a fresher get a Scala job in India, and what is expected differently from experienced candidates?
Yes, but through specific doors. Product consultancies and a handful of data platform teams hire freshers into Scala and train them, and campus hiring at large retail-tech and streaming engineering centres does the same. What is not common is a lateral fresher hire off a job board, because most Scala openings are backfills that assume production experience. Freshers are assessed on fundamentals and reasoning: immutability, pattern matching, higher-order functions, collections complexity, and whether you can explain your own code. Experienced candidates are assessed on incidents. Expect questions on why a Spark stage skewed, why a service froze with every thread in Await.result, how you handled the Akka licence change, and what your team did about compile times. If you are a fresher, contributing to an open source Scala library or writing a small Spark or fs2 project with tests will do more for you than another certificate.
Is Scala still worth learning in 2026, or is it declining?
It is worth learning if you are deliberate about why. Scala is not growing into new general-purpose backend work the way it did in the mid-2010s, and plenty of teams that would once have chosen it now pick Kotlin or modern Java. But the code already running does not disappear, and two areas remain solidly Scala: Spark data platforms, where Scala is the native API and remains the fastest and best-typed way to write jobs, and functional backends at banks and consultancies where the Typelevel and ZIO ecosystems are entrenched. That combination of steady demand and a small candidate pool is exactly what keeps compensation high. Scala 3 also made the language genuinely nicer to write. The honest framing for a career decision: learn Scala as a specialisation on top of solid JVM and distributed systems skills, not as your only language.
Scala or PySpark for data engineering jobs in India?
PySpark has more openings, Scala has better ones. Most Indian data engineering roles are advertised on Python because analysts and data scientists already use it and the DataFrame API is the same underneath, so for pure SQL-and-DataFrame pipelines PySpark is a perfectly good answer and there is no performance difference worth discussing. Scala pulls ahead in three situations: user-defined functions, where a Python UDF pays a serialisation and interpreter round trip per row while a Scala one runs inside the JVM; anything touching Spark internals such as custom sources, accumulators or the RDD API; and streaming work with Structured Streaming or Kafka where types catch mistakes early. The strongest position is both, since the platform is the same. If you already know Python, adding Scala Spark is a matter of weeks and it moves you toward the platform-engineering roles rather than the pipeline-authoring ones.
Should I learn Scala 2 or Scala 3, and Cats Effect or ZIO?
Learn Scala 3 and be able to read Scala 2. New services start on the 3.3 LTS line, but Spark still has no Scala 3 build, so data engineering interviews will show you 2.13 code and you should be comfortable with implicit, implicit class and the older collection idioms. Knowing how implicit maps onto given, using and extension covers most of the gap. On effect systems, either choice is fine and neither is a wrong answer in an interview: Cats Effect has a small, compositional core and pairs with fs2, http4s, doobie and circe, while ZIO ships more batteries including its own test framework, logging, config and ZLayer for dependency injection. Learn one properly rather than both superficially, because interviewers probe depth: cancellation, resource safety and error channels, not which library you prefer.
Introduction
Scala in 2026 sits in an unusual place: the language itself is calmer and smaller than it was a decade ago, while the systems built on it are bigger than ever. Scala 3 has settled into its 3.3 LTS line, given and using replaced the old implicit keyword soup, enums and opaque types are now part of the core language, and Scala 2.13 still runs an enormous amount of production code because Apache Spark set the ecosystem's binary-compatibility clock. Almost all paying Scala work today is one of two shapes: data engineering on Spark, or purely functional backend services built on Cats Effect, ZIO, http4s or Play. Interviews mirror that split precisely.
Indian Scala hiring is concentrated rather than broad, which changes how screens feel. Data platform teams at large retail and streaming companies recruit Scala mostly around Spark; investment banks with Bengaluru and Mumbai engineering centres run pricing, risk and settlement systems on it; and product consultancies staff pure functional Scala projects for overseas clients. Because the candidate pool is small, interviewers skip surface questions and go deep in the first thirty minutes. Expect to be asked why foldRight blows the stack, what a Task not serializable exception actually means, how implicit search picks a winner, and how your team handled the Akka licence change.
This page works through 45 Scala interview questions ordered basic to advanced, with runnable code on most of them. The basic block covers evaluation semantics, case classes, pattern matching, collections, variance and sbt. The intermediate block is where offers are usually decided: type classes, given resolution, ExecutionContext starvation, Cats Effect versus Future, Spark serialization and shuffle tuning, and the Pekko migration. The advanced block covers tagless final, opaque types, Scala 3 inline and macros, Mirror-based derivation, match types, compile-time cost, JVM memory behaviour and structured concurrency on JDK 21 virtual threads. Every answer names the production failure mode the question is really testing.
Ready to practice Scala interviews?
Don't just read, practice these Scala questions live with an AI interviewer that asks follow-ups and scores your answers.