ZetCode

Maven dependency scopes

last modified November 8, 2026

In this article we show how dependency scopes control when a dependency is available in a Maven project. The examples use Maven 3.10 and Java 25.

Maven builds several classpaths for a project: one for compiling the main code, one for compiling and running the tests, and one for running the application. The scope of a dependency decides which of these classpaths it is on, and whether it is passed on to projects that depend on our project.

Overview of dependency scopes

Maven has six dependency scopes.

ScopeCompileTestRuntimeTransitiveTypical use
compileyesyesyesyeslibraries used by the code; the default
runtimenoyesyesyesJDBC drivers, logging backends
testnoyesnonotesting frameworks
providedyesyesnonoAPIs supplied by the server, such as the Servlet API
systemyesyesnonoa JAR from a fixed path; deprecated
import————importing a BOM into dependencyManagement

The Compile, Test, and Runtime columns tell whether the dependency is on the classpath for compiling the main code, for compiling and running the tests, and for running the application. Tools that package an application with its dependencies, such as the Assembly or Shade plugins, use the runtime classpath.

The example project

Our example project uses one dependency of each common scope.

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>scopes</artifactId>
    <version>1.0.0</version>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.15.0</version>
        </dependency>

        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>2.0.20</version>
        </dependency>

        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.6.6</version>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <version>6.1.0</version>
            <scope>provided</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>6.1.3</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.27.7</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

Gson and the SLF4J API have no scope element, so they use the default compile scope. Logback, the logging backend, is a runtime dependency. The Servlet API is provided, and JUnit and AssertJ are test dependencies.

src/main/java/com/example/User.java
package com.example;

public record User(String name, String occupation) {}

A simple record that we convert to JSON.

src/main/java/com/example/App.java
package com.example;

import com.google.gson.Gson;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class App {

    private static final Logger log = LoggerFactory.getLogger(App.class);

    static String toJson(User user) {
        return new Gson().toJson(user);
    }

    public static void main(String[] args) {
        log.info("Application started");

        var user = new User("John Doe", "gardener");
        System.out.println(toJson(user));

        log.info("Logging backend: {}", LoggerFactory.getILoggerFactory().getClass().getName());
    }
}

The application converts a user to JSON with Gson and logs through the SLF4J API. It also prints the class of the logger factory, which shows which logging backend was found at runtime.

src/main/resources/logback.xml
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%-5level %logger{20} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="info">
        <appender-ref ref="STDOUT"/>
    </root>
</configuration>

A short Logback configuration that prints the level, the logger name, and the message.

The dependency:tree goal shows all dependencies with their scopes, including the transitive ones. We come back to the transitive dependencies at the end of the article.

$ mvn dependency:tree
[INFO] com.example:scopes:jar:1.0.0
[INFO] +- com.google.code.gson:gson:jar:2.15.0:compile
[INFO] |  \- com.google.errorprone:error_prone_annotations:jar:2.50.0:compile
[INFO] +- org.slf4j:slf4j-api:jar:2.0.20:compile
[INFO] +- ch.qos.logback:logback-classic:jar:1.6.6:runtime
[INFO] |  \- ch.qos.logback:logback-core:jar:1.6.6:runtime
[INFO] +- jakarta.servlet:jakarta.servlet-api:jar:6.1.0:provided
[INFO] +- org.junit.jupiter:junit-jupiter:jar:6.1.3:test
[INFO] |  +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test
[INFO] |  |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] |  |  +- org.junit.platform:junit-platform-commons:jar:6.1.3:test
[INFO] |  |  +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] |  |  \- org.jspecify:jspecify:jar:1.0.0:test
[INFO] |  +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test
[INFO] |  \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test
[INFO] |     \- org.junit.platform:junit-platform-engine:jar:6.1.3:test
[INFO] \- org.assertj:assertj-core:jar:3.27.7:test
[INFO]    \- net.bytebuddy:byte-buddy:jar:1.18.3:test
...

Compile scope

The compile scope is the default. A compile dependency is on all classpaths, it is packaged with the application, and it is passed on to projects that depend on ours. Use it for libraries the code calls directly, such as Gson and the SLF4J API in our example.

We run the application with the Exec plugin, which uses the runtime classpath. The log lines come from Logback, so the runtime dependency was found, although our code never refers to it.

$ mvn -q compile exec:java -Dexec.mainClass=com.example.App
INFO  com.example.App - Application started
{"name":"John Doe","occupation":"gardener"}
INFO  com.example.App - Logging backend: ch.qos.logback.classic.LoggerContext

Runtime scope

A runtime dependency is needed to run and test the application, but not to compile it. Our code is written against the SLF4J API, and Logback is plugged in only at runtime. This keeps the code independent of a particular logging backend; replacing Logback with another backend means changing only the POM.

The scope is enforced. In a copy of the project we add a class that uses Logback classes directly.

src/main/java/com/example/LogLevels.java
package com.example;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import org.slf4j.LoggerFactory;

public class LogLevels {

    public static void quiet() {
        var root = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
        root.setLevel(Level.WARN);
    }
}

The class casts the SLF4J logger to the Logback logger to change the log level. Logback is not on the compile classpath, so the compilation fails. If code really needs the backend classes, the dependency must have the compile scope.

$ mvn compile
[ERROR] /home/user/direct/src/main/java/com/example/LogLevels.java:[3,30] package ch.qos.logback.classic does not exist
[ERROR] /home/user/direct/src/main/java/com/example/LogLevels.java:[4,30] package ch.qos.logback.classic does not exist
[ERROR] BUILD FAILURE
...

Test scope

A test dependency is available only when compiling and running the tests. It is not packaged and not passed on to other projects.

src/test/java/com/example/AppTest.java
package com.example;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;

class AppTest {

    @Test
    void convertsUserToJson() {
        var json = App.toJson(new User("Jane Doe", "teacher"));

        assertThat(json)
                .startsWith("{")
                .contains("\"name\":\"Jane Doe\"")
                .contains("\"occupation\":\"teacher\"");
    }
}

The test uses JUnit and the fluent assertions of AssertJ. Using these classes in src/main/java would fail to compile.

$ mvn test
[INFO] Running com.example.AppTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.198 s -- in com.example.AppTest
[INFO] BUILD SUCCESS
...

Provided scope

A provided dependency is needed for compilation, but the runtime environment supplies it. The typical example is the Servlet API: a servlet container such as Tomcat or Jetty already contains it, so the web application must not bring its own copy.

src/main/java/com/example/HelloServlet.java
package com.example;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

@WebServlet("/user")
public class HelloServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("application/json");
        resp.getWriter().println(App.toJson(new User("John Doe", "gardener")));
    }
}

The servlet compiles against the Jakarta Servlet API, but the API classes are not part of the runtime classpath. Note that since Jakarta EE 9 the packages are jakarta.servlet; the old javax.servlet API is only for legacy containers.

The includeScope option of dependency:list shows which dependencies are on a classpath. The compile classpath contains the compile and provided dependencies; the runtime classpath contains the compile and runtime dependencies.

$ mvn dependency:list -DincludeScope=compile
[INFO]    com.google.code.gson:gson:jar:2.15.0:compile -- module com.google.gson
[INFO]    com.google.errorprone:error_prone_annotations:jar:2.50.0:compile -- module com.google.errorprone.annotations
[INFO]    org.slf4j:slf4j-api:jar:2.0.20:compile -- module org.slf4j
[INFO]    jakarta.servlet:jakarta.servlet-api:jar:6.1.0:provided -- module jakarta.servlet
...
$ mvn dependency:list -DincludeScope=runtime
[INFO]    com.google.code.gson:gson:jar:2.15.0:compile -- module com.google.gson
[INFO]    com.google.errorprone:error_prone_annotations:jar:2.50.0:compile -- module com.google.errorprone.annotations
[INFO]    org.slf4j:slf4j-api:jar:2.0.20:compile -- module org.slf4j
[INFO]    ch.qos.logback:logback-classic:jar:1.6.6:runtime -- module ch.qos.logback.classic
[INFO]    ch.qos.logback:logback-core:jar:1.6.6:runtime -- module ch.qos.logback.core
...

System scope

The system scope works like provided, but the JAR is taken from a path on the local disk instead of a repository.

pom.xml (dependencies)
<dependencies>
    <dependency>
        <groupId>com.google.code.gson</groupId>
        <artifactId>gson</artifactId>
        <version>2.15.0</version>
        <scope>system</scope>
        <systemPath>${project.basedir}/lib/gson-2.15.0.jar</systemPath>
    </dependency>
</dependencies>

The systemPath element gives the location of the JAR file. Maven 3.10 still builds such a project, but it warns that the scope is deprecated, and since 3.10 it rejects system dependencies in POMs that come from a repository. Instead of the system scope, install the JAR into a repository, for example with mvn install:install-file.

$ mvn compile
[WARNING] 'dependencies.dependency.scope' for com.google.code.gson:gson:jar declares usage of deprecated 'system' scope  @ line 20, column 20
[WARNING] 'dependencies.dependency.systemPath' for com.google.code.gson:gson:jar should not point at files within the project directory, ${project.basedir}/lib/gson-2.15.0.jar will be unresolvable by dependent projects @ line 21, column 25
[INFO] BUILD SUCCESS
...

Import scope

The import scope is used only in the dependencyManagement section, together with the pom type. It copies the managed dependencies of another POM, called a BOM (bill of materials), into our project.

pom.xml (excerpt)
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.junit</groupId>
            <artifactId>junit-bom</artifactId>
            <version>6.1.3</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.platform</groupId>
        <artifactId>junit-platform-launcher</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

The JUnit BOM manages the versions of all JUnit artifacts, so the two test dependencies are declared without versions. The junit-platform-launcher artifact is often added for IDEs that run tests through the JUnit Platform. Importing a BOM does not add any dependency by itself; it only supplies versions. See the Maven BOM article for more.

$ mvn dependency:tree
[INFO] com.example:import-scope:jar:1.0.0
[INFO] +- org.junit.jupiter:junit-jupiter:jar:6.1.3:test
[INFO] |  +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test
[INFO] |  |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] |  |  \- org.junit.platform:junit-platform-commons:jar:6.1.3:test
[INFO] |  +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test
[INFO] |  \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test
[INFO] \- org.junit.platform:junit-platform-launcher:jar:6.1.3:test
[INFO]    +- org.junit.platform:junit-platform-engine:jar:6.1.3:test
[INFO]    +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO]    \- org.jspecify:jspecify:jar:1.0.0:test
...

Scope transitivity

When a dependency has its own dependencies, Maven derives the scope of each transitive dependency from both scopes. The rows of the table show the scope of our direct dependency; the columns show the scope the transitive dependency has in that dependency's POM.

compileprovidedruntimetest
compilecompile—runtime—
providedprovided—provided—
runtimeruntime—runtime—
testtest—test—

Provided and test dependencies of a library are never passed on, which is shown by the dashes. Our dependency tree illustrates the other rules: logback-core is a compile dependency of Logback, which we declared with the runtime scope, so it becomes a runtime dependency. The dependencies of JUnit and AssertJ become test dependencies.

Best practices

Source

Introduction to the dependency mechanism

In this article we have shown how dependency scopes control the classpaths of a Maven project.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Java tutorials.