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.
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.
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.
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:
| OS | Default 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=mavenCredentials 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=trueOptional 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:
| Command | What it does |
|---|---|
cs setup | Ensures 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 search | Install a Scala application (scala, scalac, sbt, scala-cli, scalafmt, metals) as a launcher; on Linux into ~/.local/share/coursier/bin |
cs launch | Run an application by name or straight from Maven coordinates without installing it |
cs resolve | Print the resolved module set; -t for a tree, -T for a reverse tree, --what-depends-on for the paths to one module |
cs fetch | Resolve and download, printing file paths; --classpath joins them with the OS path separator, --sources and --javadoc pick other artifacts |
cs java / cs java-home | Download and run a JVM, or print the home directory of one, so CI images need no preinstalled JDK |
cs bootstrap | Create 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.MainLibrary 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_3The 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_TTLdeliberately:Inffor jobs that must not touch the network once warm,0sfor jobs that test against fresh snapshots. - Point CI at an internal mirror with
mirror.propertiesor 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_CREDENTIALSfrom 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
| Symptom | Likely cause | Fix |
|---|---|---|
| 401 or 403 from a private repository | Credential host does not match the URL host, or a mirror rewrote the URL to a host with no entry | Match the host exactly; check which URL the mirror produces |
| Snapshot never updates | Cached copy is younger than the TTL | COURSIER_TTL=0s for that run |
| Different classpath locally and in CI | A publishLocal in ~/.ivy2/local shadows the release | Delete the local publish; do not cache that directory |
| Resolution fails with conflicting ranges | Two modules declare version ranges with no common version | Override one side or upgrade the module that declares the narrow range |
| Runtime NoSuchMethodError | Highest-wins reconciliation crossed a binary-incompatible boundary | Inspect with cs resolve -t; upgrade, exclude or override deliberately |
| Very slow first resolve behind a proxy | JVM proxy properties not set, so connections time out before falling back | Set 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
- Install
csand reproduce your project's resolution withcs resolve -tfor its key dependencies. - Run
evictedin sbt and review every eviction that crosses a major or early-semver boundary. - Add
versionSchemeto every library you publish. - Move repository, mirror and credential settings out of build files into
mirror.properties, credentials files or CI environment variables. - Cache
~/.cache/coursier/v1(or yourCOURSIER_CACHE) in CI with a key that hashes all build definition files. - Remove dynamic versions and snapshots from release builds, and document every
dependencyOverridesentry with its reason. - Read about the build tools that embed Coursier next: sbt internals and the Mill build tool.