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.
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 WireCodecProducts, 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.
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):
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:
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:
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:
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:
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 WireCodecA @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:
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 UnknownwithWrappedCases 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.
final case class Contact(name: String, email: Option[String]) derives WireCodec
Contact("bob", None) // {"name": "bob"} — no "email" key at allTwo 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:
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:
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:
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:
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:
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:
given BsonCodec[Address] = WireCodec[Address].toBsonCodecDocumentCodecBridge.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:
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:
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 WireCodecNeither 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
getCollectionpath,mongo4sasks the driver forBsonDocumentand does its own encode/decode. So dolistIndexes,listCollections,runCommand,aggregate,distinctandwatch— all of them readBsonDocument/BsonValue. Your registry is never asked aboutA. - On the
getDirectCollectionpath,mongo4sregisters the derivedWireCodec[A]ahead of the client's registry, so aCodec[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
BsonDocumentcodec. Sincemongo4sreads and writesBsonDocumenteverywhere, a registry that replaces the defaults instead of extending them fails every single operation withCodecConfigurationException: Can't find a codec for … BsonDocument. Always build yours asCodecRegistries.fromRegistries(yours, MongoClientSettings.getDefaultCodecRegistry). - It governs everything past
underlying.MongoClient/MongoDatabase/MongoCollectioneach expose the driver object they wrap, and there the driver's rules apply in full —collection.underlying.withDocumentClass(classOf[Foo])resolvesFoofrom your registry,mongo4snot 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.