Every time sbt, Mill or Scala CLI turns a line like "org.typelevel" %% "cats-effect" % "3.5.4" into JARs on a classpath, Coursier is doing the work. It is a dependency resolver and artifact fetcher written in Scala, shipped both as a library that build tools embed and as a command-line tool, cs, that installs JVMs and Scala applications. Most developers only notice it when something goes wrong: a 401 from a private repository, a snapshot that will not update, a NoSuchMethodError caused by a version nobody asked for, or a CI job that downloads half of Maven Central on every run.

This article explains what Coursier does from first principles, so those failures stop being mysterious: how resolution reaches a fixed point, how version conflicts are reconciled, how the cache is laid out and when it goes back to the network, and how repositories, mirrors and credentials are configured. Then it walks through diagnosing a real conflict and setting Coursier up for CI. The settings graph, scopes and incremental compilation that surround it inside sbt are covered in the sbt internals article.

Advertisement

Two jobs: resolve and fetch

Coursier does two separate jobs, and keeping them apart clarifies most problems.

  • Resolution turns a set of root dependencies into a complete, consistent set of module versions. It reads metadata (Maven POM files or Ivy descriptors), never JARs.
  • Fetching downloads the artifacts (JARs, source JARs, javadoc JARs) for the resolved modules into a local cache and hands back file paths.

sbt has used Coursier as its default library-management backend since 1.3.0, replacing Apache Ivy, which is why sbt resolution became parallel and much faster around then. Mill and Scala CLI use it too, as does Metals when it bootstraps itself. A resolution problem therefore usually reproduces outside your build tool with the cs CLI, which is the fastest way to separate a Coursier question from a build-definition question.

Root depsbuild fileFetch metadataPOM / ivy.xml, parallelExpand and reconcileexclusions, versionsnew modules? loopResolutionfixed pointno changeFetch artifactsJARs, parallelClasspathfile pathsLocal cachev1/https/host/pathhit or downloadRepositoriesmirrors, credentialsmiss or TTL expiredMetadata goes through the same cache; only changing items obey the TTL
Resolution iterates over metadata until no new modules or version changes appear, then fetching downloads artifacts. Both read through the local cache, which consults repositories only on a miss or when a changing item's TTL has expired.

How resolution reaches a fixed point

Resolution is a loop. Coursier starts with the root dependencies, downloads their metadata in parallel, reads the transitive dependencies each one declares, applies exclusions, and adds any new modules to the working set. Whenever two paths request different versions of the same module, it reconciles them into one version. Because changing a version changes which POM is read, and therefore which transitive dependencies appear, the loop repeats until a pass produces no new modules and no version changes: a fixed point.

Reconciliation is where Coursier differs from Maven. Maven picks the version nearest to the root in the dependency tree, so the outcome depends on declaration order and depth. Coursier's default, like Ivy's and Gradle's, is to pick the highest requested version, and version ranges are intersected; if no version satisfies every range, resolution fails rather than guessing. Highest-wins is usually what you want for binary compatibility within a library line, but it means adding one dependency can silently upgrade a library used elsewhere.

A concrete case shows the difference. Your build declares cats-core 2.9.0 directly, and a library you add depends on cats-core 2.10.0. Maven keeps 2.9.0, because your direct declaration is nearest to the root, and the library may then fail at runtime calling a method that only exists in 2.10.0. Coursier selects 2.10.0, because it is the highest requested, and your own direct declaration is evicted. Neither choice is wrong in general; what matters is knowing which rule applies, because it decides whether a direct declaration in your build file is a promise or only a minimum. Under Coursier it is only a minimum. To make a version binding you need an explicit override, and a lower version than something else requires is a decision you must check by hand.

That is where versionScheme comes in. When library authors publish versionScheme := Some("early-semver") (or semver-spec, pvp), sbt can tell whether an eviction crossed a binary-incompatible boundary and fail the build instead of warning. Coordinates also carry Scala's binary version: in sbt %% and on the CLI org::name:version append the suffix, so io.circe::circe-core:0.14.10 resolves circe-core_3 or circe-core_2.13 depending on the Scala version in use; ::: appends the full Scala version for compiler plugins.

Advertisement

The cache and the TTL

The cache is a directory tree that mirrors repository URLs, so https://repo1.maven.org/maven2/org/typelevel/cats-core_3/... is stored under v1/https/repo1.maven.org/maven2/org/typelevel/cats-core_3/.... Default locations:

OSDefault cache directory
Linux, FreeBSD~/.cache/coursier/v1
macOS~/Library/Caches/Coursier/v1
Windows%LOCALAPPDATA%\Coursier\Cache\v1
Older versions~/.coursier/cache/v1, still used if it exists and the new location does not

The location is chosen in order: an explicit option (--cache on the CLI, the coursierCache setting in sbt), then the COURSIER_CACHE environment variable, then the coursier.cache Java property, then the default. Setting COURSIER_CACHE is the simplest way to put the cache on a CI volume.

Released artifacts are immutable, so once a file is in the cache Coursier never re-downloads it. Changing items are different: -SNAPSHOT artifacts and the Maven metadata files that list available versions. For those Coursier applies a time-to-live, 24 hours by default, controlled by COURSIER_TTL or the coursier.ttl property (inside sbt the shaded name lmcoursier.internal.shaded.coursier.ttl). Values are durations such as 5 min or 10s; 0s always rechecks and Inf never does. This is the answer to "my snapshot will not update": the cached copy is younger than the TTL.

Repositories, mirrors and credentials

By default Coursier reads the local Ivy repository (~/.ivy2/local, where sbt publishLocal writes) and Maven Central. Because the local repository comes first, a stale publishLocal of a library can shadow the real release, which is a common source of works-on-my-machine bugs. On the CLI, -r adds a repository, --no-default drops the defaults, and COURSIER_REPOSITORIES replaces them with a pipe-separated list:

export COURSIER_REPOSITORIES="ivy2Local|central|https://nexus.example.com/repository/maven-public"

In a company you rarely want builds talking to Maven Central directly. Mirrors redirect one or more repository URLs to an internal proxy. Coursier reads them from a mirror.properties file in its configuration directory (COURSIER_MIRRORS selects a specific file, COURSIER_EXTRA_MIRRORS adds one, COURSIER_CONFIG_DIR changes the directory) and also from Maven's ~/.m2/settings.xml mirror entries, unless COURSIER_MAVEN_SETTINGS=false:

# mirror.properties
internal.from=https://repo1.maven.org/maven2;https://plugins.gradle.org/m2
internal.to=https://nexus.example.com/repository/maven-public
internal.type=maven

Credentials are matched by host. They can come from COURSIER_CREDENTIALS (lines of host user:password, optionally host(realm) user:password) or from credentials.properties in ~/.config/coursier/ on Linux and Windows, or ~/Library/Application Support/Coursier/ on macOS:

# credentials.properties
nexus.host=nexus.example.com
nexus.username=ci-reader
# password: write it from your secret store at job start; never commit this file
nexus.password=REPLACE_ME
nexus.https-only=true

Optional keys per prefix are realm, https-only (default false), auto (default true) and pass-on-redirect (default false). The last matters when a repository redirects downloads to blob storage: by default credentials are not forwarded to the redirect target, which is the safe behaviour. Proxies come from the standard JVM properties (https.proxyHost, https.proxyPort and friends) or the proxy section of Maven's settings file.

The cs command line

The cs launcher bundles the resolver with environment management. The subcommands worth knowing:

CommandWhat it does
cs setupEnsures a JVM is installed, the install directory is on PATH, and standard Scala tools are installed; optional, every other command works without it
cs install / cs searchInstall a Scala application (scala, scalac, sbt, scala-cli, scalafmt, metals) as a launcher; on Linux into ~/.local/share/coursier/bin
cs launchRun an application by name or straight from Maven coordinates without installing it
cs resolvePrint the resolved module set; -t for a tree, -T for a reverse tree, --what-depends-on for the paths to one module
cs fetchResolve and download, printing file paths; --classpath joins them with the OS path separator, --sources and --javadoc pick other artifacts
cs java / cs java-homeDownload and run a JVM, or print the home directory of one, so CI images need no preinstalled JDK
cs bootstrapCreate a standalone launcher from dependencies, useful for shipping internal CLI tools
# Install standard tools, then build a classpath without a build tool
cs install scalafmt scala-cli
# A ready-made classpath for an ad hoc javac or java invocation
CP=$(cs fetch --classpath org.postgresql:postgresql:42.7.4 com.zaxxer:HikariCP:5.1.0)
java -cp "$CP:out" com.example.Main

Library and version numbers in these examples are illustrative; use cs resolve to see what is actually published.

Worked example: a version conflict

Suppose a service starts failing in tests with java.lang.NoSuchMethodError: 'cats.kernel.Order cats.kernel.Order$.fromLessThan(...)' after someone added a new library. The method exists in the cats version you depend on directly, so something else must have changed the version. Reproduce the resolution outside the build, using the same coordinates the build declares, with the Scala suffix written out so the CLI sees exactly what sbt sees:

$ cs resolve -t io.circe:circe-core_3:0.14.10 org.example:new-lib_3:1.2.0
$ cs resolve io.circe:circe-core_3:0.14.10 org.example:new-lib_3:1.2.0 \
    --what-depends-on org.typelevel:cats-kernel_3

The tree shows every path to cats-kernel and the version each path requested; --what-depends-on narrows it to the chains that pull it in. In this scenario the new library was built against an older cats line and the tree reveals that a second, unexpected module brings in a cats version from a different binary series. Inside sbt the same information comes from evicted (which lists every eviction and whether the version scheme considers it safe) and from dependencyTree, available after adding addDependencyTreePlugin to project/plugins.sbt.

There are three ways out, in order of preference. Upgrade the offending library to a release built against your cats line, which fixes the cause. Exclude the transitive dependency if it is genuinely unused. Or force a version with dependencyOverrides, which applies the override at resolution time without adding a direct dependency; it is a statement that you have checked binary compatibility yourself, so pin it with a comment linking to the reason and a test that exercises the affected code path.

Coursier in CI

Two goals compete in CI: speed, which favours a warm cache, and reproducibility, which favours knowing exactly what was downloaded. Both are achievable. Cache the Coursier directory keyed on a hash of every file that affects resolution, and restore on a prefix so a changed build still starts warm:

# GitHub Actions
- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/coursier/v1
      ~/.sbt
      ~/.ivy2/cache
    key: deps-${{ runner.os }}-${{ hashFiles('**/*.sbt', 'project/build.properties', 'project/*.scala') }}
    restore-keys: deps-${{ runner.os }}-
  • Avoid dynamic versions (latest.release, ranges) and snapshots in anything that ships; with released, immutable versions the cache key fully determines the result.
  • Set COURSIER_TTL deliberately: Inf for jobs that must not touch the network once warm, 0s for jobs that test against fresh snapshots.
  • Point CI at an internal mirror with mirror.properties or Maven settings, so an outage of a public repository does not stop releases and every artifact passes through one place you can audit.
  • Feed credentials through COURSIER_CREDENTIALS from the CI secret store rather than files baked into images.
  • Never cache ~/.ivy2/local; a stale local publish restored from cache will shadow real releases.

Failure modes

SymptomLikely causeFix
401 or 403 from a private repositoryCredential host does not match the URL host, or a mirror rewrote the URL to a host with no entryMatch the host exactly; check which URL the mirror produces
Snapshot never updatesCached copy is younger than the TTLCOURSIER_TTL=0s for that run
Different classpath locally and in CIA publishLocal in ~/.ivy2/local shadows the releaseDelete the local publish; do not cache that directory
Resolution fails with conflicting rangesTwo modules declare version ranges with no common versionOverride one side or upgrade the module that declares the narrow range
Runtime NoSuchMethodErrorHighest-wins reconciliation crossed a binary-incompatible boundaryInspect with cs resolve -t; upgrade, exclude or override deliberately
Very slow first resolve behind a proxyJVM proxy properties not set, so connections time out before falling backSet the proxy properties or Maven settings proxy entry

A corrupted cache entry from an interrupted download is rare but possible; deleting the specific module directory under the cache path forces a clean re-download without discarding the whole cache.

What to do next

  1. Install cs and reproduce your project's resolution with cs resolve -t for its key dependencies.
  2. Run evicted in sbt and review every eviction that crosses a major or early-semver boundary.
  3. Add versionScheme to every library you publish.
  4. Move repository, mirror and credential settings out of build files into mirror.properties, credentials files or CI environment variables.
  5. Cache ~/.cache/coursier/v1 (or your COURSIER_CACHE) in CI with a key that hashes all build definition files.
  6. Remove dynamic versions and snapshots from release builds, and document every dependencyOverrides entry with its reason.
  7. Read about the build tools that embed Coursier next: sbt internals and the Mill build tool.
Key takeaway: Coursier resolves by looping over metadata until module versions reach a fixed point, picking the highest requested version, then fetches artifacts through a URL-shaped local cache. Released files are cached forever; snapshots and version listings obey a 24-hour TTL you can change with COURSIER_TTL. Configure repositories, mirrors and credentials outside build files, diagnose conflicts with cs resolve, and cache the Coursier directory in CI with a key over every build definition file.