SENDLINE open source
open source / mongo4s / bson codecs

BSON codecs

Every codec ultimately produces a mongo4s.bson.BsonDocumentCodec[A] (entity ⇄ org.bson.BsonDocument) or, for the AST-free path below, a WireCodec[A]. Bring whichever backend fits — mongo4s never registers a global CodecProvider, so backends never collide inside one process.

Module Backend Notes
mongo4s-bson-medeia medeia derives BsonDocumentCodec
mongo4s-bson-zio zio-bson zio.bson.BsonCodec (add zio-schema-bson yourself to derive one from a Schema)
mongo4s-bson-calypso calypso hand-written forProductN codecs
mongo4s-bson-direct mongo4s itself WireCodec[A], AST-free — see below
mongo4s-bson-cats-data cats-core BsonEncoder/BsonDecoder/WireCodec for NonEmptyList/Chain/NonEmptyVector/NonEmptySet/NonEmptyMap, WireCodec for Ior

bson-direct — AST-free WireCodec

medeia/zio-bson/calypso all build an intermediate org.bson.BsonValue tree before it ever reaches the driver. WireCodec[A] skips that: it's derived via Mirror and writes straight to the driver's own streaming BsonWriter/BsonReader, the same low-level SPI jsoniter-scala uses for JSON — no BsonDocument is ever built, on either side. No third-party codec dependency needed; bson-direct is transitively pulled in by mongo4s-core.

scala
import mongo4s.bson.direct.WireCodec

final case class Address(city: String, zip: String) derives WireCodec
final case class Person(id: String, name: String, tags: List[String], address: Address) derives WireCodec

sealed trait Shape derives WireCodec
object Shape:
  final case class Circle(radius: Double)                   extends Shape derives WireCodec
  final case class Rectangle(width: Double, height: Double) extends Shape derives WireCodec

Products, Option, Either, nested case classes, sealed traits/enums (via a _type discriminator field, written first), and self-/mutually-recursive types all derive directly — recursive derivation is deferred behind a lazy val internally so a type's own given never forces itself mid-construction.

Nothing derives without being asked. A type gets a WireCodec from derives WireCodec, from a given you wrote, or from an existing BsonEncoder/BsonDecoder pair — never from merely being a case class. So a field whose type you forgot to model is a compile error naming that type, not a codec invented for it, and an opaque type keeps whatever representation you give it instead of borrowing the one underneath. The exception is the cases of an enum or the leaves of a sealed trait: they cannot carry derives themselves, so the sum derives them — the same line circe draws.

scala
final case class Address(city: String) derives WireCodec
final case class Person(name: String, address: Address) derives WireCodec  // Address must have one

opaque type UserId = String
object UserId:
  given WireCodec[UserId] = ScalarWireCodec[String].imap(apply)(_.value)   // yours, not String's

enum Shape derives WireCodec:   // Circle and Square derive with it
  case Circle(radius: Double)
  case Square(side: Double)

String, Int, Long, Double, Boolean, BigDecimal, Instant, UUID and ObjectId are read and written natively, in the same BSON representation the BsonEncoder/BsonDecoder path uses — Decimal128 for BigDecimal, Date for Instant, ObjectId for ObjectId, a string for UUID — so the server indexes and range-compares them exactly as it would on a collection opened with getCollection. A BSON date holds milliseconds, so an Instant loses anything finer on the way in. A UUID is also read from the binary subtype 4 other drivers write; a legacy subtype 3 is refused, since nothing in it says which byte order wrote it. Float, Short, Byte, Char and Array[Byte] are native too, as the Java driver writes them — a double, an int, an int, a one-character string and a binary of subtype 0 — and read every width the others read, refusing a value outside their range (40000 into a Short) or a string that is not one character. Array[Byte] is a binary, not an array of numbers, and any binary subtype reads as its bytes. Any other type with an existing BsonEncoder/BsonDecoder bridges automatically, at the cost of one BsonValue per field instead of zero. BigInt, LocalDateTime, OffsetDateTime, ZonedDateTime and Duration have no default, for the reason LocalDate has none below: BSON has several ways to hold each, and picking one for you would be a guess.

Where a collection already stores one of these another way, ScalarWireCodec has the common alternatives ready to be made the given — instantAsEpochMillis, instantAsEpochSeconds, instantAsIsoString, uuidAsBinary — and two for LocalDate, which has no default because neither representation is the obvious one: localDateAsString (2026-10-31) and localDateAsDate(zone) (the start of that day in zone, UTC unless given):

scala
given ScalarWireCodec[Instant]   = ScalarWireCodec.instantAsEpochSeconds
given BsonEncoder[Instant]       = ScalarWireCodec.instantAsEpochSeconds.toBsonEncoder
given ScalarWireCodec[LocalDate] = ScalarWireCodec.localDateAsDate()

The second line matters for a type the library already encodes: queries take Instant's own BsonEncoder, a BSON date, before a ScalarWireCodec of yours, so without it a filter on such a field would compare a date with a number. A type with no encoder of its own, like LocalDate, needs no second line.

List/Vector/Seq/Set/Array write a real BSON array, and Map[String, A] a real BSON document keyed by its own keys. A map keyed by anything else is the same document with each key written as a string by its KeyCodec: Int, Long and UUID have one, and so does any type whose ScalarWireCodec writes a string — an enum stored by its value, say — so Map[Provider, Score] needs nothing of its own. A stored key that does not read back fails the decode under that key's name, and field.at(key) reaches one entry of such a map in a query the same way it does for a String key. Every other Iterable collection with a scala.collection.Factory (Queue, ArraySeq, ListSet, LazyList, SortedSet/TreeSet given an Ordering, …) gets an array-shaped WireCodec too, generically — no dedicated given needed per type.

getDirectCollection registers the derived codec with the driver via CodecRegistries.fromCodecs(...), so insert/find/replace/update/delete/bulkWrite decode straight to A — genuinely zero BsonDocument construction on the hot path, not just a thinner bridge:

scala
import mongo4s.bson.direct.WireCodec
import mongo4s.repositories.BaseMongoRepository

final case class Person(id: String, name: String, age: Int) derives WireCodec
object Person:
  given PrimaryKey[Person, String] = PrimaryKey.single("id")(_.id)

for
  db         <- client.getDatabase("myapp")
  collection <- db.getDirectCollection[Person]("people")
  repo        = BaseMongoRepository(collection)
  _          <- repo.insertOne(Person("1", "bob", 30))
yield ()

The trade for that speed is strictness: derivation requires every modelled field to be present, unless its decoder supplies a default — which Option does. So a projection that drops a field the entity declares cannot be read back through the entity's own codec. That is what selectAs is for — see Partial reads — and getCollection with a BsonDocumentCodec of your own remains the escape hatch below that.

Strictness stops at the field list, though — not at BSON numeric widths. A document that stores 42 as an Int32 where the model says Long (written by mongosh, by a $inc, or by another service) reads the same through getDirectCollection as through getCollection: any whole number in range is accepted, and one that cannot be a whole number of that type — 7.5 into a Long, a value past 2^63 — is refused on both. A Double target is the exception on both paths: every BSON number converts to one, so a very large Int64 or a long Decimal128 rounds rather than being refused, exactly as BsonDecoder[Double] has always done. The rule is written once, in BsonDecoder, and the wire codec defers to it, so the two cannot drift apart.

What that costs: a field stored in the width the model declares is read straight off the wire, allocating nothing; a field stored in some other numeric width costs one BsonValue for the conversion. Matching data pays nothing for the leniency.

aggregate decodes through BsonDocumentCodec because its output shape isn't A; aggregateDirect is the AST-free counterpart for an output that has a WireCodec. distinct still goes through BsonDecoder, which reads one BsonValue per result rather than a document, so there is no tree to skip. Everything else — Filter/Update/Field construction — is identical regardless of which codec backs the collection.

Field naming

By default derives WireCodec writes field and discriminator names exactly as they appear in the Scala source. To match an existing collection's naming convention (snake_case, say), bring a WireCodecConfig into scope before deriving — it reuses the same mongo4s.bson.FieldNaming the query layer (Filter/Update/Sort) already uses, rather than a second, independent naming mechanism:

scala
import mongo4s.bson.direct.WireCodecConfig

given WireCodecConfig = WireCodecConfig.SnakeCase

final case class Person(firstName: String, lastName: String) derives WireCodec // writes "first_name"/"last_name"

Field naming and discriminator naming (the _type value for sealed traits/enums — the case names, not the field that holds them) are independently configurable. WireCodecConfig is built by starting from Default (or SnakeCase) and chaining withFieldNaming, withDiscriminatorNaming, withEncodeEmptyCasesAsString, withOmitNoneFields, withDecodeMissingAsDefault, withDiscriminatorField, withWrappedCases, withFallbackCase — the same shape WatchOptions uses, and the reason new derivation options can be added in a minor release without breaking binary compatibility:

scala
given WireCodecConfig = WireCodecConfig.SnakeCase.withDiscriminatorNaming(FieldNaming.snakeCase)

getDirectCollection takes no naming of its own: the codec is what writes the field names, so it is what decides how a query spells them. A derived WireCodec — a product, an enum or a sealed trait — reports the WireCodecConfig it was built with, and so does one mapped from it with imap or iemap; a hand-written one says so by overriding fieldNaming. getCollection still takes the parameter, because a BsonDocumentCodec from medeia, calypso or zio-bson carries its own naming configuration that mongo4s cannot read; there the parameter still has to match the codec.

A collection has one naming, so the entity and everything nested in it should be derived under the same WireCodecConfig. A nested type derived under a different one — a case of a sealed trait included — writes its own spelling, but Field.of(_.nested.field) renders every segment through the root's, so a query on such a field matches nothing. To rename one field, use @stored below rather than a config of its own for the class: Field.of reads the annotation, and cannot read a config.

FieldNaming applies per derived segment only. Stored names — Field.stored, a PrimaryKey's field names, a $lookup's foreign field given as a string, map keys — are used verbatim. FieldNaming.snakeCase splits on every capital, so userID becomes user_i_d; FieldNaming.overrides(Map("userID" -> "userId"), fallback = snakeCase) is the way out of that. FieldNaming.kebabCase is the same split with -, and FieldNaming.identity the default.

One field of one class is renamed with @stored, and kept out of the document with @notStored:

scala
import mongo4s.bson.direct.{notStored, stored}

final case class Period(@stored("from_") from: Instant, @stored("to_") to: Instant, totalDays: Int) derives WireCodec

final case class Runner(name: String, @notStored trainer: Option[Trainer], @notStored attempts: Int = 0) derives WireCodec

A @stored name is used verbatim — the naming does not touch it — and Field.of, PrimaryKey.of and selectAs spell the field the same way, so Field.of[Period, Instant](_.from) filters on from_. A @notStored field is neither written nor read: it comes back as its default, or None, whatever the document holds, so it needs one of the two, and its type needs no codec. Querying it does not compile.

encodeEmptyCasesAsString = true writes a parameterless case (case object/empty case class) as a bare BSON string instead of a {"_type": "..."} document — only safe when that case is nested inside another document, not when it's the root type of a collection (BSON's root value must always be a document).

Three more settings shape a sum, for collections another writer already defined:

scala
given WireCodecConfig = WireCodecConfig.Default
  .withDiscriminatorField("kind") // {"kind": "Circle", "radius": 2.0}
  .withWrappedCases(true)         // {"Circle": {"radius": 2.0}} — the case is the one field, kind is not written
  .withFallbackCase("Unknown")    // a case it does not know reads as the case object Unknown

withWrappedCases skips an _id the server stored first and reads the first field after it as the case. withFallbackCase is for rolling deployments, where a writer learns a new case before every reader does: it applies to each sum derived under the config that has a case of that name, which has to be a case object, and leaves the others failing on an unknown case as before.

Absent Option fields

A field holding None is left out of the document entirely rather than stored as an explicit null — the key costs nothing on disk, and a collection of mostly-empty optional fields gets materially smaller. Reads are unaffected either way: a missing field decodes to None through the same defaultOnMissing that has always covered it, and a document already carrying an explicit null still decodes to None, so a collection written before this keeps working unchanged.

scala
final case class Contact(name: String, email: Option[String]) derives WireCodec

Contact("bob", None) // {"name": "bob"} — no "email" key at all

Two things do change with the field gone. {field: null} as a filter still matches, since MongoDB reads that predicate as "null or missing", but $exists: true no longer matches a None, and a sparse index on that field no longer includes those documents. Where either matters, withOmitNoneFields(false) restores the explicit null:

scala
given WireCodecConfig = WireCodecConfig.Default.withOmitNoneFields(false)

A nested Option does not survive a round-trip, with or without the flag: Some(None) is written as null and reads back as None, because BSON has one null and both layers map onto it. This is true of the AST path (BsonEncoder/BsonDecoder) as well. If you need to tell "absent" from "present but empty", model it as an enum or a wrapper case class rather than Option[Option[A]].

The flag governs fields of a derived product only. A None inside an array is still written as null regardless, because dropping an element would shift every position after it. The AST bridges (medeia, zio-bson, calypso) follow their own library's rule here, not this flag.

An update writes None the way the default codec does: field.set(None) renders $unset, so a document updated to None has the shape of one inserted with it, and setOnInsert(None) adds nothing to the update, leaving the field out of an inserted document. field.setNull is the explicit null, for a collection derived with withOmitNoneFields(false) or one that relies on $exists or a sparse index seeing the field. Both apply to Repository.updateField too.

Missing fields and case-class defaults

A missing field fails the decode unless it is an Option, even when the case class gives it a default. That is deliberate: a default is the constructor's, and silently standing in for what the database holds is how a document written without the field reads back as something nobody stored. withDecodeMissingAsDefault(true) is the opt-in for the case it exists for — a field added to a case class that older documents do not have:

scala
given WireCodecConfig = WireCodecConfig.Default.withDecodeMissingAsDefault(true)

final case class Account(name: String, status: Status = Status.Active) derives WireCodec
// {"name": "ann"} reads back as Account("ann", Status.Active)

A field that is there always wins, and a field with no default still fails when it is missing. The default is evaluated per decode, as the constructor would — a UUID.randomUUID() default gives each document its own. One case to know before turning it on: an Option field with a default takes that default when it is missing, not None — since: Option[Int] = Some(2020) reads a document without since as Some(2020). An explicit null is a value, not a missing field, and still reads as None. Encoding does not change: a default is written like any other value.

Hand-writing a codec: contramap/map/emap/imap

A type that is not a case class, enum or sealed trait needs a codec written for it — WireCodec[A] is just WireEncoder[A] with WireDecoder[A], and both halves compose the same way BsonEncoder/BsonDecoder already do elsewhere in mongo4s:

scala
import mongo4s.bson.direct.WireCodec

enum Provider(val value: String):
  case Stripe extends Provider("stripe")
  case Adyen  extends Provider("adyen")

object Provider:
  def from(value: String): Option[Provider] = Provider.values.find(_.value == value)

  given WireCodec[Provider] =
    WireCodec[String].iemap(raw => from(raw).toRight(s"Unsupported provider: $raw"))(_.value)

imap (total in both directions) and iemap (decode can fail — reported as BsonError.InvalidValue) replace a hand-rolled WireCodec.instance(...) for the common case of one type wrapping another: no BsonWriter/BsonReader calls to write by hand, and the wrap/unwrap logic lives in exactly one place instead of being duplicated across the encode and decode sides.

The enum case above is common enough to have its own spelling. ScalarWireCodec.byValue stores each case as the value it names, refuses two cases stored alike, and reports a stored value no case matches; being a ScalarWireCodec, it filters too:

scala
given ScalarWireCodec[Provider] = ScalarWireCodec.byValue(Provider.values)(_.value)

A type that is only ever read — a projection, a view another service writes — can have a codec without an encoder: WireCodec.decodeOnly(decoder) reads with the decoder and refuses to write, naming why.

Both keep what the codec underneath knows about an absent field: a wrapper over an Option leaves the field out of the document when it is empty, and reads the missing field back as that same empty value — through iemap too, as long as the validation accepts it. They keep the codec's naming as well, so a derived entity wrapped in iemap to validate what it reads still has its queries spelled the way it writes — and its shape: as a case of a sealed trait or a branch of an Either, its fields stay inline beside _type, as they were before it was wrapped.

Resolution order matters when a type qualifies for more than one instance. An existing BsonEncoder/BsonDecoder pair — hand-written, or coming from mongo4s-bson-medeia/-zio/-calypso — is bridged into a WireCodec, so a type carrying those instances resolves without derives WireCodec. A WireCodec of the type's own — derives WireCodec, or a ScalarWireCodec in its companion — wins over that bridge.

ScalarWireCodec — one codec for documents and for queries

The rule to keep in mind: a document is written and read by a WireCodec, and a value inside a query — a filter, an update, a key, a selectAs — by a BsonEncoder/BsonDecoder. A ScalarWireCodec is both, so a scalar type needs nothing more; a document-shaped type that appears inside a query needs toBsonCodec, below.

An opaque type over a primitive (opaque type UserId = String) needs a WireCodec[UserId] to be written into and read from documents, a BsonEncoder[UserId] for Field.of[...].equalTo and PrimaryKey.single, and a BsonDecoder[UserId] for selectAs and distinct. A ScalarWireCodec[A] — the type every primitive in WirePrimitiveInstances has — is all three:

scala
import mongo4s.bson.direct.ScalarWireCodec

opaque type UserId = String

object UserId:
  def apply(value: String): UserId = value
  extension (id: UserId) def value: String = id

  given ScalarWireCodec[UserId] = ScalarWireCodec[String].imap(UserId.apply)(_.value)

BsonEncoder and BsonDecoder take a ScalarWireCodec from the type's companion, or from an import, as their lowest-priority instance: a BsonEncoder[UserId] written by hand still wins, so the given BsonEncoder[UserId] = summon[ScalarWireCodec[UserId]].toBsonEncoder line earlier versions needed is now redundant and harmless. The decoder reads with the wire codec's own leniency — an Int32 into a Long — and its errors. Encoding builds one BsonValue per value, which is cheap on the query-construction path BsonEncoder runs on. ScalarWireCodec is deliberately narrower than WireCodec: a derived case class or sum type (derives WireCodec) is genuinely document-shaped, so it's never typed as ScalarWireCodec — asking for one (ScalarWireCodec[SomeCaseClass]) fails to compile instead of misbehaving at runtime.

A document-shaped codec is not bridged on its own, since a BsonEncoder built from a WireCodec that is itself built from a BsonEncoder would chase its own tail. toBsonCodec bridges it on request, for Field.of(_.address).equalTo(address), a push into a List[Address], or selectAs:

scala
given BsonCodec[Address] = WireCodec[Address].toBsonCodec

DocumentCodecBridge.toDocumentCodec[Address] serves as well, and is the BsonDocumentCodec a getCollection asks for.

Either[A, B]

WireCodec[Either[A, B]] derives given WireCodec[A] and WireCodec[B], flat like a derived sealed trait ({"_type": "Circle", "radius": 2.0}) rather than wrapped in a Left/Right envelope — the discriminator is each branch's own runtime type name, and a document-shaped branch (a case class) has its fields inlined directly instead of nested under a "value" key:

scala
final case class ValidationError(message: String)
final case class Approved(reference: String)

// Right(Approved("abc")) -> {"_type": "Approved", "reference": "abc"}
// Left(ValidationError("bad input")) -> {"_type": "ValidationError", "message": "bad input"}
val result: Either[ValidationError, Approved] = ...

A scalar branch (no fields to inline, e.g. a bare String) keeps a "value" field, since a bare BSON scalar can't also carry the discriminator in the same slot. The two branches must resolve to distinguishable type names — Either[Foo, Foo], two types that share a runtime class name once generics are erased (Either[List[Int], List[String]]), an opaque type and the type it wraps (Either[UserId, String]), or two anonymous classes — and that is refused with an IllegalArgumentException rather than risking a silent wrong-branch decode later. So is a branch with a field of its own named _type, which inlined next to the discriminator would overwrite it. The refusal comes when the Either codec is built: at once for a codec you summon, but for a field of a derived case class only on the first encode or decode of that class, since its field codecs are built lazily so that recursive types derive. A test that writes one value of each entity finds it before production does.

The discriminator is ClassTag[A].runtimeClass.getSimpleName — for Int/Long/Double/Boolean/etc, that's the JVM primitive's name ("int", not "Integer"), since ClassTag[Int]'s runtimeClass is the primitive class, not the boxed one. Not a bug, just worth knowing if one branch is a bare numeric/boolean type.

bson-cats-data — cats.data support

NonEmptyList/Chain/NonEmptyVector/NonEmptySet/NonEmptyMap get BsonEncoder/BsonDecoder and WireCodec instances from mongo4s-bson-cats-data, delegating to the already-existing List/Vector/Set/Map instances rather than reimplementing BSON encoding:

scala
import cats.data.NonEmptyList
import mongo4s.bson.direct.WireCodec

import mongo4s.bson.catsdata.CatsDataWireInstances.given // the WireCodecs a derived entity needs
import mongo4s.bson.catsdata.CatsDataBsonInstances.given // the BsonEncoder/BsonDecoder a filter or update needs

final case class Team(members: NonEmptyList[String]) derives WireCodec

Neither import is found on its own; both are mongo4s instances. A model derived by medeia, zio-bson or calypso needs that library's own instance for NonEmptyList: the bridges carry a backend's codecs into mongo4s, not mongo4s's back into the backend.

NonEmptySet/NonEmptyMap additionally need a cats.Order for their element/key type — the same Order you'd already need to construct one of these types directly.

Ior[A, B] gets a WireCodec too, discriminated by each branch's own type name rather than "Left"/"Right"/"Both". Unlike Either, which inlines a case class branch's fields, Ior's single-sided branches always nest their payload under "value". Both doesn't have a single "own" type — it holds an A and a B at once — so its discriminator is the two names joined ("String+Foo"), with each side nested under its own "left"/"right" key rather than inlined, to avoid a silent field-name collision if A and B happen to share a field. Its two names must differ as Either's do, and the refusal comes at the same moment.

Codecs and the driver's registry

The driver resolves codecs from a process- or client-wide CodecRegistry. mongo4s does not: a codec is resolved per collection, from the BsonDocumentCodec[A] or WireCodec[A] in implicit scope at the getCollection / getDirectCollection call. That is what lets medeia, zio-bson, calypso and bson-direct coexist in one process without colliding.

So a CodecRegistry you set on MongoClientSettings is not where an entity codec belongs — mongo4s never asks it for one, on either path:

  • On the getCollection path, mongo4s asks the driver for BsonDocument and does its own encode/decode. So do listIndexes, listCollections, runCommand, aggregate, distinct and watch — all of them read BsonDocument/BsonValue. Your registry is never asked about A.
  • On the getDirectCollection path, mongo4s registers the derived WireCodec[A] ahead of the client's registry, so a Codec[A] registered there does not silently shadow the codec the collection was opened with.

That does not make it inert, though. It is still load-bearing in two ways:

  • It has to keep providing the driver's own BsonDocument codec. Since mongo4s reads and writes BsonDocument everywhere, a registry that replaces the defaults instead of extending them fails every single operation with CodecConfigurationException: Can't find a codec for … BsonDocument. Always build yours as CodecRegistries.fromRegistries(yours, MongoClientSettings.getDefaultCodecRegistry).
  • It governs everything past underlying. MongoClient/MongoDatabase/MongoCollection each expose the driver object they wrap, and there the driver's rules apply in full — collection.underlying.withDocumentClass(classOf[Foo]) resolves Foo from your registry, mongo4s not involved.

So: set a registry for driver-level defaults, or for a type you handle through the escape hatch — not to route the entities mongo4s already has codecs for.

built from README.md at v6.0.0 edit this page on GitHub