Maven Inheritance, Aggregation & the Effective POM

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Maven 3.10.0. Every example was run on JDK 26 on 2026-10-16, with Maven 3.9.16 and Maven 4.0.0-rc-7 (a release candidate) run wherever the page names them. The projects are scratch copies of the tidepool build: a company-wide parent org.example.corp:example-corporate-parent:7, the project parent io.tidepool:tidepool-parent:1.0.0 and its modules tidepool-model, tidepool-engine and tidepool-cli. Absolute paths in console output and in XML were shortened to ...; lines that were left out are marked, and the help:effective-settings header, which prints the account and host name of the machine, was removed. Plugin versions are the stable releases found on Maven Central on 2026-10-16.

A Maven build is almost never one POM. The tidepool-engine module declares ten lines of its own, and still compiles with the right Java release, deploys to the right repository and names the right license, because the rest comes from other POMs. Two different relationships connect those POMs, and they are the most mixed-up pair of ideas in Maven:

  • Inheritance is declared in the child with <parent>. The child takes configuration from its parent.

  • Aggregation is declared in the aggregator with <modules>. The aggregator builds other projects when it is built.

They are independent. This page explains both, the hidden parent every POM has (the Super POM), the rules that decide what is copied from parent to child and how two configurations are merged, and the commands that show the result. The element reference is on POM Fundamentals, the reactor that executes an aggregation is on Multi-Module Builds & the Reactor, and plugin configuration in general is on Maven Plugins & Their Configuration.

The big picture

The diagram below is the layout of POMs for the scratch tidepool build (the layout of one module’s directories is on Philosophy, Coordinates & Directory Layout). Solid arrows point from a child to the POM it inherits from; dashed arrows point from an aggregator to the projects it builds.

graph BT SUPER["Super POM - built into Maven"] CORP["example-corporate-parent 7"] PARENT["tidepool-parent 1.0.0"] MODEL["tidepool-model"] ENGINE["tidepool-engine"] CLI["tidepool-cli"] ALL["tidepool-all - aggregator only"] CORP -->|inherits| SUPER PARENT -->|inherits| CORP MODEL -->|inherits| PARENT ENGINE -->|inherits| PARENT CLI -->|inherits| PARENT PARENT -.->|modules| MODEL PARENT -.->|modules| ENGINE PARENT -.->|modules| CLI ALL -.->|modules| CORP ALL -.->|modules| PARENT

tidepool-parent is both the parent of the three modules and their aggregator, which is the usual layout and the reason the two ideas get confused. example-corporate-parent is a parent only: building it builds nothing else. tidepool-all is an aggregator only: it inherits from nothing but the Super POM and exists so that a single command can build the company parent and the project together. All three situations are run below.

The example POMs

The company parent is shared by many projects and lives in its own repository. It holds what every project of the company needs: organization, license, the Java release, the pinned plugin versions and where to deploy releases.

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <groupId>org.example.corp</groupId>
  <artifactId>example-corporate-parent</artifactId>
  <version>7</version>
  <packaging>pom</packaging>

  <name>Example Corporation parent POM</name>
  <url>https://example.org/engineering</url>
  <inceptionYear>2020</inceptionYear>

  <organization>
    <name>Example Corporation</name>
    <url>https://example.org</url>
  </organization>

  <licenses>
    <license>
      <name>Apache-2.0</name>
      <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
      <distribution>repo</distribution>
    </license>
  </licenses>

  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <junit.version>6.1.3</junit.version>
  </properties>

  <build>
    <pluginManagement>
      <plugins>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.16.0</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-jar-plugin</artifactId>
          <version>3.5.1</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>3.6.0</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>

  <distributionManagement>
    <repository>
      <id>corp-releases</id>
      <url>file:///var/tmp/example-releases</url>
    </repository>
  </distributionManagement>
</project>

(The xsi:schemaLocation attribute and the namespace declaration for xsi were left out of this listing. The deploy target is a local directory; nothing was deployed.) The project parent inherits from it, lists the modules and manages the dependency versions of the project; it sits in tidepool/pom.xml next to the three module directories, with the company parent in a sibling directory corporate/:

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.example.corp</groupId>
    <artifactId>example-corporate-parent</artifactId>
    <version>7</version>
    <relativePath>../corporate/pom.xml</relativePath>
  </parent>

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-parent</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>

  <name>tidepool</name>
  <description>Tide height prediction toolkit</description>
  <url>https://tidepool.example.org</url>

  <scm>
    <connection>scm:git:https://example.org/tidepool.git</connection>
    <url>https://example.org/tidepool</url>
  </scm>

  <modules>
    <module>tidepool-model</module>
    <module>tidepool-engine</module>
    <module>tidepool-cli</module>
  </modules>

  <properties>
    <commons-csv.version>1.14.1</commons-csv.version>
    <picocli.version>4.7.7</picocli.version>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-model</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-engine</artifactId>
        <version>${project.version}</version>
      </dependency>
      <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-csv</artifactId>
        <version>${commons-csv.version}</version>
      </dependency>
      <dependency>
        <groupId>info.picocli</groupId>
        <artifactId>picocli</artifactId>
        <version>${picocli.version}</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

(Again without the xsi attributes.) A module is short, because almost everything is inherited. This is the whole tidepool-engine/pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>io.tidepool</groupId>
    <artifactId>tidepool-parent</artifactId>
    <version>1.0.0</version>
  </parent>

  <artifactId>tidepool-engine</artifactId>
  <name>tidepool-engine</name>

  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
    </dependency>
  </dependencies>
</project>

The module declares no groupId and no version (both come from the parent), no dependency versions (they come from the parent’s dependencyManagement, see Dependency Management & BOMs) and no relativePath, because the parent is in the default place. The three modules compile and package: mvn -B verify in tidepool/ on Maven 3.10.0 ended with BUILD SUCCESS for the four projects. These POMs are this page’s own lab setup, not examples from the Maven documentation.

The parent element and relativePath

<parent> names a POM by its coordinates (groupId, artifactId, version). The child must spell out all three; Maven 3 never infers them (Maven 4 can, see below). The fourth element, relativePath, tells Maven where to look for the parent POM on disk first. Maven looks for the parent in this order:

  1. The file at relativePath, relative to the child’s directory. The default is ../pom.xml, which is why a module in a subdirectory of its parent needs nothing. The path can point at a directory (then pom.xml is appended) or a file with another name.

  2. The local repository.

  3. The remote repositories.

A file found at relativePath is accepted only when its groupId, artifactId and version match the declaration. An empty <relativePath/> skips step 1 and goes straight to the repositories, which is the right setting for a parent that is always consumed from a repository, for example a company parent that lives in another repository while the directory above the module holds something unrelated. The company parent in the tidepool example is found by the explicit <relativePath>../corporate/pom.xml</relativePath>; in a real repository, where corporate/ does not exist, the setting would be dropped or emptied and the company parent installed into a repository.

Three cases show what Maven does with a path that does not lead to the declared parent. The scratch child declares io.tidepool:wrong-parent:1, while ../pom.xml is io.tidepool:rp-parent:1. With an empty local repository, so that the parent cannot be found anywhere else, mvn -B -N validate on Maven 3.9.16 and 3.10.0 prints (the long message was cut; the Some problems line is printed by Maven itself):

[WARNING] 'parent.relativePath' of POM io.tidepool:child:1 (.../rp/child/pom.xml) points at io.tidepool:rp-parent instead of io.tidepool:wrong-parent, please verify your project structure @ line 3, column 11
[ERROR] The build could not read 1 project -> [Help 1]
[ERROR]     Non-resolvable parent POM for io.tidepool:child:1: The following artifacts could not be resolved: io.tidepool:wrong-parent:pom:1 (absent): Could not find artifact io.tidepool:wrong-parent:pom:1 in central (https://repo.maven.apache.org/maven2) and 'parent.relativePath' points at wrong local POM @ line 3, column 11 -> [Help 2]

(That output is from 3.9.16. Maven 3.10.0 prints the same warning and the same error, only the reason for the failed download differs: it says the path io/tidepool/wrong-parent/1/wrong-parent-1.pom is not allowed from central, which comes from the repository prefix files that Maven 3.10 loads for Central.) With the same child and wrong-parent:1 installed in the local repository, Maven 3.9.16, 3.10.0 and 4.0.0-rc-7 all warn about the mismatch (Some problems were encountered while building the effective model, or on Maven 4 1 problem was encountered while building the effective model) and then end with BUILD SUCCESS, because the parent was found in step 2. So the warning is not harmless noise: it means "the file you pointed at is not the parent, and I fell back to the repository". When the sibling is not the parent, set relativePath to the right file or to an empty element.

A path to a file that does not exist is treated differently by Maven 3 and Maven 4. The child above with <relativePath>../does-not-exist/pom.xml</relativePath> and the parent installed in the local repository built with BUILD SUCCESS on Maven 3.9.16 and 3.10.0: Maven 3 silently falls back to the repository. On Maven 4.0.0-rc-7 it stops:

[ERROR]     'parent.relativePath' points at '../does-not-exist/pom.xml' but no POM could be found, please verify your project structure @ line 3, column 3

Maven 3.10.0 adds validation of the value itself. The release notes say that "relativePath of a parent is validated as well, which no Maven 3.x version did so far". A path with a character that is illegal in file names is now an error. The same child with <relativePath>../par*ent/pom.xml</relativePath> printed on Maven 3.10.0:

[ERROR] 'parent.relativePath' must not contain any of these characters :"<>|?* but found * @ line 7, column 19

and built with BUILD SUCCESS on Maven 3.9.16, which never looked at the value. This is one instance of the stricter POM validation of 3.10: duplicated plugins, malformed snapshot versions and similar mistakes that were warnings in Maven 3.9 now fail the build (see Maven Versions & Release History). A build that fails on a POM that "always worked" after the upgrade is usually this.

The parent POM must also be available to everyone who uses the child as a dependency: a consumer resolves the child’s POM, then its parent’s POM, and so on up the chain. After mvn install on tidepool-all (below) with a scratch local repository, the repository held the POMs of every link in the chain:

./io/tidepool/tidepool-all/1.0.0/tidepool-all-1.0.0.pom
./io/tidepool/tidepool-cli/1.0.0/tidepool-cli-1.0.0.pom
./io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom
./io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom
./io/tidepool/tidepool-parent/1.0.0/tidepool-parent-1.0.0.pom
./org/example/corp/example-corporate-parent/7/example-corporate-parent-7.pom

(find . -name '*.pom', sorted.) Forgetting to deploy a parent POM is a classic way to publish a library nobody can build against; install and deploy of a pom-packaged parent only publish that single POM. See Install, Deploy & Distribution Management.

The Super POM

A POM without <parent> is not parentless. Every POM ultimately inherits from the Super POM, which is a file inside the maven-model-builder JAR of the Maven installation (org/apache/maven/model/pom-4.0.0.xml). The Super POM supplies what makes mvn package work with a POM of five lines: the standard directories, the Central repository and the final name. The Super POM of Maven 3.10.0, extracted from lib/maven-model-builder-3.10.0.jar (the license comment and blank lines were removed):

<project>
  <modelVersion>4.0.0</modelVersion>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <!-- Fixed date for reproducible build -->
    <project.build.outputTimestamp>1980-02-01T00:00:00Z</project.build.outputTimestamp>
  </properties>

  <repositories>
    <repository>
      <id>central</id>
      <name>Central Repository</name>
      <url>${maven.repo.central}</url>
      <layout>default</layout>
      <snapshots>
        <enabled>false</enabled>
      </snapshots>
    </repository>
  </repositories>

  <pluginRepositories>
    <pluginRepository>
      <id>central</id>
      <name>Central Repository</name>
      <url>${maven.repo.central}</url>
      <layout>default</layout>
      <snapshots>
        <enabled>false</enabled>
      </snapshots>
    </pluginRepository>
  </pluginRepositories>

  <build>
    <directory>${project.basedir}/target</directory>
    <outputDirectory>${project.build.directory}/classes</outputDirectory>
    <finalName>${project.artifactId}-${project.version}</finalName>
    <testOutputDirectory>${project.build.directory}/test-classes</testOutputDirectory>
    <sourceDirectory>${project.basedir}/src/main/java</sourceDirectory>
    <scriptSourceDirectory>${project.basedir}/src/main/scripts</scriptSourceDirectory>
    <testSourceDirectory>${project.basedir}/src/test/java</testSourceDirectory>
    <resources>
      <resource>
        <directory>${project.basedir}/src/main/resources</directory>
      </resource>
    </resources>
    <testResources>
      <testResource>
        <directory>${project.basedir}/src/test/resources</directory>
      </testResource>
    </testResources>
  </build>

  <reporting>
    <outputDirectory>${project.build.directory}/site</outputDirectory>
  </reporting>
</project>

Everything that "convention over configuration" promises is in these 60 lines, together with the lifecycle bindings of the packaging (see Build Lifecycle). The only thing a POM must state is its own coordinates; src/main/java is where sources are because this file says so, and a POM that overrides <sourceDirectory> overrides the Super POM exactly as it would override a parent.

What changed in the Super POM of Maven 3.10

Comparing the Super POM files of the three Maven versions (and the effective POM of a five-line project built on each) gives the changes that the 3.10.0 release notes list under "Changes in the Super POM":

Aspect Maven 3.9.16 Maven 3.10.0 Maven 4.0.0-rc-7

project.build.sourceEncoding, project.reporting.outputEncoding

not set

UTF-8

UTF-8

project.build.outputTimestamp

not set

1980-02-01T00:00:00Z, so builds are reproducible by default

1980-02-01T00:00:00Z

release-profile (activated by performRelease=true: source and Javadoc JARs, deploy)

present, marked as going away

removed; each project defines its own release profile

removed

pluginManagement

maven-antrun-plugin 3.1.0, maven-assembly-plugin 3.7.1, maven-dependency-plugin 3.7.0, maven-release-plugin 3.0.1

removed; each project pins its own versions

removed

<repositories> and <pluginRepositories> for central

present

present

not in the Super POM; the effective POM of the five-line project showed no repositories

Two consequences for builds that move from 3.9 to 3.10, both run: the five-line project ran mvn -N dependency:tree with dependency:3.7.0:tree on Maven 3.9.16 (the version from the Super POM) and with dependency:3.11.0:tree on Maven 3.10.0 (no version in the Super POM, so Maven resolved the latest release); and help:effective-pom -DperformRelease=true contained the source and Javadoc plugins on 3.9.16 and neither on 3.10.0, so a build that relied on that property for its source and Javadoc JARs silently stops producing them. Pin the plugins as shown on Maven Plugins & Their Configuration, and see Releasing for a release profile that is yours.

The versions of the lifecycle plugins (clean, resources, compiler, surefire, jar, install, deploy, site) are not in the Super POM, they come from the lifecycle bindings of the Maven core. They changed between the two Maven versions too, and they are visible in the same effective POM:

Plugin Maven 3.9.16 Maven 3.10.0

maven-clean-plugin

3.2.0

3.5.0

maven-resources-plugin

3.4.0

3.5.0

maven-jar-plugin

3.5.0

3.5.1

maven-compiler-plugin

3.15.0

3.16.0

maven-surefire-plugin

3.5.4

3.6.0

maven-install-plugin, maven-deploy-plugin

3.1.4

3.2.0

maven-site-plugin

3.12.1

3.22.0

(From mvn help:effective-pom of the five-line project with <packaging> left at the default jar.) The point of pinning every plugin in pluginManagement, as the company parent does, is that none of these numbers moves when somebody upgrades Maven.

What is inherited and what is not

The maven-model-builder documentation states the rule in one sentence: every model field is inherited as-is from the parent, with a few exceptions that are intentionally not inherited: modelVersion, artifactId, packaging, profiles (injected in an earlier phase) and prerequisites. In practice, with the elements most POMs use:

Element Inherited? Notes

groupId, version

yes

A child that omits them gets the parent’s. A child that sets its own version is no longer in lockstep.

artifactId, packaging, modelVersion

no

The child must give its artifactId. packaging defaults to jar in every child, even when the parent is pom.

name

no

A child without <name> has none; Maven shows the artifactId instead.

description, inceptionYear, organization, licenses, developers, contributors, issueManagement, ciManagement, mailingLists

yes

Copied as they are.

url, scm (connection, developerConnection, url), distributionManagement.site.url

yes, with the child’s artifactId appended

See below.

properties

yes, merged by key

A child property replaces a parent property of the same name; the others are added.

dependencies

yes, merged by groupId:artifactId:type:classifier

A child adds dependencies. The child’s declaration of the same dependency replaces the parent’s one.

dependencyManagement

yes

Applies to the child’s own dependencies. See Dependency Management & BOMs.

build.plugins, build.pluginManagement, build.resources, build.extensions, build.finalName and the other build elements

yes

Plugins are merged by groupId:artifactId; a plugin with <inherited>false</inherited> is not passed on.

reporting, repositories, pluginRepositories, distributionManagement

yes

Merged by id where there is one.

modules

no

Aggregation is never inherited, otherwise every module would build its parent’s modules.

profiles of the POM

no (they are applied to the POM that declares them, before inheritance)

A profile in settings.xml or activated from the command line applies to every project it is active for.

prerequisites

no

Only meaningful for plugin projects.

The table was checked on a scratch pair of POMs. The parent declares a name, a description, a url, <modules> and two plugins, one of them with <inherited>false</inherited>; the child declares only its artifactId. The values Maven computes for the child, from mvn -B -q -f child/pom.xml help:evaluate -Dexpression=... -DforceStdout for each expression (the blank lines between the answers and the JDK 26 warnings about sun.misc.Unsafe that the plugin’s XML library prints were removed):

project.name = inh-child
project.description = Parent description
project.url = https://example.org/inh/inh-child
project.packaging = jar
project.artifactId = inh-child
project.groupId = io.tidepool.demo
project.version = 1

project.name equals the artifactId because Maven shows the artifact id when there is no name; the parent’s name "Inheritance parent" did not arrive. The url is the parent’s https://example.org/inh with /inh-child appended. In the effective POM of the child, written with help:effective-pom -pl child, the plugin inherited-plugin is present, the plugin parent-only-plugin (which has <inherited>false</inherited>) is absent, and there is no <modules> element (grep -c printed 0).

Child URLs append the child’s artifact id

The five URL fields behave in a way that surprises people. If the child does not set them, the inherited value is the parent’s with the child’s artifact id appended. In the real example, the module tidepool-engine declares no URL, and its effective POM (help:effective-pom -pl tidepool-engine, Maven 3.10.0) contains:

  <url>https://tidepool.example.org/tidepool-engine</url>
  ...
  <scm>
    <connection>scm:git:https://example.org/tidepool.git/tidepool-engine</connection>
    <url>https://example.org/tidepool/tidepool-engine</url>
  </scm>

(A line of three dots replaces the unrelated elements between the two.) For url and the web URL of scm this is what you want when the layout of the site mirrors the layout of the repository. For scm.connection it produces ...tidepool.git/tidepool-engine, which is not a valid Git URL: this is a long-standing Maven behaviour, and it is the reason that a multi-module build with one Git repository sets the scm of the root and keeps the connection as it is in each module that is released on its own. Two controls exist:

  • Since Maven 3.6.1, the attribute child.project.url.inherit.append.path="false" on the parent’s <project> element stops the appending for the project URL. The attributes for the other fields are on their elements: child.scm.connection.inherit.append.path, child.scm.developerConnection.inherit.append.path and child.scm.url.inherit.append.path on <scm>, and child.site.url.inherit.append.path on <site>. The first of them was run: with <project child.project.url.inherit.append.path="false" ...> in tidepool/pom.xml, the effective POM of tidepool-engine had <url>https://tidepool.example.org</url>. The three scm attributes were not run here.

  • Since Maven 3.5.0, a property project.directory in the child, when it is defined, is used in place of the artifact id, so that a module whose directory name is not its artifact id still gets the right URL. It is not inherited.

Variables are resolved after inheritance

The reference says that "these variables are processed after inheritance". A ${...} in a parent does not freeze the parent’s value; the child’s value is the one that counts. The scratch parent had <description>${project.artifactId} on ${project.version}, owned by ${owner}</description> and owner set to parent-team; the child set owner to child-team. The effective POM of the child contained:

  <description>inh-child on 1, owned by child-team</description>
  ...
    <owner>child-team</owner>

${project.artifactId} is the child’s artifact id as well, and the child’s owner won. This is how a parent can define a template and each module fill it in, and also how a module’s property silently changes what the parent’s text means. dependencyManagement entries written as ${project.version} in the tidepool parent rely on it: each module resolves them with the version of the module, which is the same everywhere because the version is inherited. Properties are the topic of POM Fundamentals.

Merging configuration: combine.children and combine.self

Both lists of plugins and the <configuration> of each plugin are XML, and merging two XML trees needs a rule. The default rule is: elements are matched by name, a child element replaces a parent element of the same name, and elements that exist on only one side are kept. For a list (several elements with the same name) the default is therefore "the child’s list replaces the parent’s list", not "the lists are added". Two attributes on the child’s element change that:

  • combine.children="append" adds the child’s sub-elements after the parent’s.

  • combine.self="override" drops the parent’s configuration for that element completely.

The parent from the experiment configures a (fictional) plugin with a list and a map:

<plugin>
  <groupId>io.tidepool.demo</groupId>
  <artifactId>demo-maven-plugin</artifactId>
  <version>1.0</version>
  <configuration>
    <stations>
      <station>harbour</station>
      <station>estuary</station>
    </stations>
    <labels>
      <region>north</region>
    </labels>
  </configuration>
</plugin>

Three children configure the same plugin with <stations><station>lagoon</station></stations> and <labels><depth>deep</depth></labels>, with no attribute, with combine.children="append" on stations, and with combine.self="override" on both stations and labels. The merged <configuration> from help:effective-pom (Maven 3.10.0; the plugin is never executed, so it does not have to exist):

Child stations labels

no attribute

lagoon — the child’s list replaced the parent’s

depth=deep and region=north — maps are merged by key

combine.children="append" on stations

harbour, estuary, lagoon

depth=deep and region=north

combine.self="override" on stations and labels

lagoon

depth=deep only — region is gone

This is the actual merged XML for the second child; the attribute is kept in the effective POM:

<configuration>
  <stations combine.children="append">
    <station>harbour</station>
    <station>estuary</station>
    <station>lagoon</station>
  </stations>
  <labels>
    <depth>deep</depth>
    <region>north</region>
  </labels>
</configuration>

(Indentation trimmed.) The reference page warns that combine.self="override" wins when both attributes are on one element. Use combine.children="append" for lists you want to extend (compiler arguments, excludes, extra resources directories), and combine.self="override" for the case "the parent configuration must not apply here at all". Anything else is the default merge and is rarely what you meant for a list. The same attributes apply to pluginManagement configuration and to <executions> merged by id; how plugin configuration works in general is on Maven Plugins & Their Configuration.

inherited: false

A plugin, or an execution, can opt out of being passed to children with <inherited>false</inherited>. It is for a task that belongs to the aggregator only, such as a plugin that generates an aggregated report or a distribution archive of the whole project, and that would otherwise run once in every module. The experiment above showed the effect: the plugin with <inherited>false</inherited> was configured in the parent and absent from the child’s effective POM.

Aggregation with modules

A POM with <packaging>pom</packaging> and a <modules> list is an aggregator. Every <module> is the path of a directory that holds a pom.xml (or the path to a POM file), relative to the aggregator. When Maven is run on the aggregator it reads the module POMs, builds a reactor with all of them and runs the requested phase on each, in an order decided by their dependencies, not by the order of the list. The tidepool/ aggregator, run with mvn -B validate on Maven 3.10.0, printed:

[INFO] Reactor Build Order:
[INFO]
[INFO] tidepool                                                           [pom]
[INFO] tidepool-model                                                     [jar]
[INFO] tidepool-engine                                                    [jar]
[INFO] tidepool-cli                                                       [jar]

(The first lines of the reactor listing; the rest of the build was left out.) The aggregator is built first when it is also the parent, because a project cannot be built before its parent is known. The reactor, its flags (-pl, -am, -amd, -rf, -N, -T) and the failure policies are the subject of Multi-Module Builds & the Reactor; the command-line side is on Running Maven from the Command Line.

Because aggregation and inheritance are independent, three layouts are all legal, and all three were run:

A parent that is also the aggregator (the usual case): tidepool/pom.xml above. The modules inherit from it and it lists them.

A parent that aggregates nothing: the company parent. In corporate/, mvn -B validate ran a single project, with no reactor summary (the output was just the project header example-corporate-parent, [ pom ], and BUILD SUCCESS). Nothing in the company parent knows who inherits from it, and it should not.

An aggregator that is nobody’s parent: tidepool-all/pom.xml. It has no <parent>, so it inherits from the Super POM only, and it lists the company parent and the project, both outside its own directory:

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-all</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>

  <modules>
    <module>../corporate</module>
    <module>../tidepool</module>
  </modules>
</project>

mvn -B validate in tidepool-all/ (Maven 3.10.0; the [INFO] prefix and timings were removed from the summary):

Reactor Build Order:

Example Corporation parent POM                                     [pom]
tidepool                                                           [pom]
tidepool-model                                                     [jar]
tidepool-engine                                                    [jar]
tidepool-cli                                                       [jar]
tidepool-all                                                       [pom]

Reactor Summary:

Example Corporation parent POM 7 ................... SUCCESS
tidepool 1.0.0 ..................................... SUCCESS
tidepool-model 1.0.0 ............................... SUCCESS
tidepool-engine 1.0.0 .............................. SUCCESS
tidepool-cli 1.0.0 ................................. SUCCESS
tidepool-all 1.0.0 ................................. SUCCESS
BUILD SUCCESS

The company parent comes first, then the project parent, then the modules, as the parent relations require; the aggregator has nothing that its modules need, so it ends up last. The relativePath of tidepool-parent points at ../corporate/pom.xml, and this is the situation where it matters: both are in the reactor and Maven wires them together. An aggregator like this is the usual way to build several repositories at once in a monorepo or in a CI job, without making any of them the parent of the others. A directory name can also be a module without any <parent> relation in the other direction: modules do not have to declare the aggregator as their parent.

To build only the aggregator and none of its modules, -N (--non-recursive) is used; mvn -B -N validate in tidepool/ printed a single successful project, tidepool-parent.

Maven 4: subprojects, model 4.1.0, parent inference

Maven 4 renames modules to subprojects. Model version 4.1.0 adds a <subprojects> element that is analogous to the now deprecated, but still usable, <modules>. The 4.0.0 model, with <modules>, keeps working: the tidepool/ build ran on Maven 4.0.0-rc-7 and ended with BUILD SUCCESS for the four projects (with a warning that no root directory could be found, see below). A build POM with model version 4.1.0 needs Maven 4:

<project xmlns="http://maven.apache.org/POM/4.1.0" root="true">
  <modelVersion>4.1.0</modelVersion>

  <groupId>io.tidepool.m4</groupId>
  <artifactId>tidepool-m4-parent</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>

  <subprojects>
    <subproject>model</subproject>
    <subproject>engine</subproject>
  </subprojects>

  <properties>
    <maven.compiler.release>21</maven.compiler.release>
  </properties>
</project>

(Without the xsi attributes.) The two subprojects are shorter than their Maven 3 equivalents, because with a 4.1.0 model the <parent> element may omit groupId and version, which Maven infers from the parent POM it finds:

  <parent>
    <groupId>io.tidepool.m4</groupId>
    <artifactId>tidepool-m4-parent</artifactId>
  </parent>

On Maven 4.0.0-rc-7 mvn -B validate built the parent and both subprojects (SUCCESS for all three). The What’s New page goes further: with a 4.1.0 model <parent/> is enough, with the default relativePath .., and a pom-packaged parent with no <subprojects> or <modules> section discovers the subdirectories that contain a pom.xml. I ran both: a copy of the build with the <subprojects> section removed and <parent/> in each subproject also built all three projects with BUILD SUCCESS. The same files on Maven 3.10.0 and 3.9.16 fail:

[ERROR]     Malformed POM .../pom.xml: Unrecognised tag: 'subprojects' (position: START_TAG seen ...</packaging>\n\n  <subprojects>... @13:16)  @ .../pom.xml, line 13, column 16 -> [Help 2]
[ERROR]     'modelVersion' of '4.1.0' is newer than the versions supported by this version of Maven: [4.0.0]. Building this project requires a newer version of Maven. @ line 6, column 17

(The root="true" attribute of the 4.1.0 model, used by Maven 4 to mark the root of the project so that it does not need a .mvn directory, is also unknown to Maven 3.10.0: with it, the first error is Unknown attribute 'root' for tag 'project'. Without it, Maven 4.0.0-rc-7 prints the "Unable to find the root directory" warning seen above.)

A POM that only Maven 4 can read must not be what consumers download, and Maven 4 handles that with its consumer POM. mvn install of the 4.1.0 build into a scratch local repository wrote two POMs per project: tidepool-m4-engine-1.0.0-build.pom (the original) and tidepool-m4-engine-1.0.0.pom, the one repository users resolve. The consumer POM is a 4.0.0 POM that Maven 3 can read, with the inferred version filled in and the build-only elements gone:

<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>io.tidepool.m4</groupId>
    <artifactId>tidepool-m4-parent</artifactId>
    <version>1.0.0</version>
  </parent>
  <artifactId>tidepool-m4-engine</artifactId>
</project>

(The parent’s consumer POM has no subprojects and no root either.) The What’s New page says that flattening, which would remove the <parent> element too, is off by default and is switched on with the user property maven.consumer.pom.flatten; the run above did not enable it, and the parent reference stayed. The migration steps are on Migrating to Maven 4. Maven 4.0.0-rc-7 is a release candidate, so everything in this section may change before 4.0.0.

Inheritance versus aggregation

Inheritance Aggregation

Declared in

the child, with <parent>

the aggregator, with <modules> (<subprojects> in Maven 4)

Direction

child to parent; the parent does not know its children

aggregator to modules; the modules do not know the aggregator

Purpose

share configuration (properties, managed versions, plugins, metadata)

build several projects with one command and in the right order

At resolution time

the parent POM must be available to everyone who uses the child, as a file or as an artifact

only needed when building; the aggregator is not a dependency of anything

Packaging required

a pom parent, normally

pom

The reference closes the comparison with a rule of thumb that works well: use inheritance to remove repetition, use aggregation to build things together, and keep them in one POM only when the layout is simple. The tidepool parent does both because the modules live in subdirectories of it. Separate them when the shared configuration is shared by projects that are not built together (the company parent) or when the reactor should include projects that share nothing (tidepool-all). The converse mistake is to add a module to <modules> expecting it to inherit: the module still needs its own <parent>.

Designing the parents

Two levels of parent are enough for most organisations.

The company (organisation) parent is a pom-packaged project that is released by itself, often in its own repository and with its own version (7 above), and consumed by every project through <parent>. What belongs in it, because it is the same for all projects and rarely changes:

  • organisation, license, issueManagement, and the other metadata of the company;

  • distributionManagement and the scm conventions;

  • pluginManagement with the pinned version of every plugin the company uses, and the configuration they all share;

  • the Java release, source encoding and similar properties;

  • enforcer rules (see The Enforcer Plugin).

What does not belong: dependencies that only some projects need (put them in a BOM, see Dependency Management & BOMs), anything that depends on the layout of a particular project, and plugin executions that would run in every project of the company. Because a project has one parent only, a company parent that carries a long dependencyManagement makes it hard to also inherit from a framework’s parent POM; importing a BOM with <scope>import</scope> does not use up the <parent> slot.

The project parent (tidepool-parent) inherits from the company parent, aggregates the modules and holds the project’s own managed dependency versions, the module list and any properties the modules share. A change to it is a change in all the modules at once; the version is the project version, and the modules inherit it, so a release changes one number (see Versions, Snapshots & Ranges for ${revision} and CI-friendly versions that keep that single number out of every file).

The company parent is itself a project to release and deploy, and it must be in a repository before any project builds from outside the repository that holds it. In the example the relativePath is set because everything is in one directory tree; a project that is built on its own from a clean checkout would either drop that line, so that the repository is searched, or install the company parent first.

The effective POM and the effective settings

What Maven does with a build is determined by the effective POM: the Super POM, the chain of parents, the active profiles, the interpolation of every ${...} and the default lifecycle bindings, merged. It is what remains after all the rules of this page have been applied, and the Help plugin prints it:

mvn -B help:effective-pom -pl tidepool-engine -Doutput=effective-pom.xml

Without -Doutput the POM is printed to the console (surrounded by log lines, which is why -q and -Doutput are used when you want a file). For tidepool-engine on Maven 3.10.0 the effective POM has 307 lines. A selection, in the order of the file, with the lines that were left out replaced by ...:

<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>io.tidepool</groupId>
    <artifactId>tidepool-parent</artifactId>
    <version>1.0.0</version>
  </parent>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-engine</artifactId>
  <version>1.0.0</version>
  <name>tidepool-engine</name>
  <description>Tide height prediction toolkit</description>
  <url>https://tidepool.example.org/tidepool-engine</url>
  <inceptionYear>2020</inceptionYear>
  ...
  <distributionManagement>
    <repository>
      <id>corp-releases</id>
      <url>file:///var/tmp/example-releases</url>
    </repository>
  </distributionManagement>
  <properties>
    <commons-csv.version>1.14.1</commons-csv.version>
    <junit.version>6.1.3</junit.version>
    <maven.compiler.release>21</maven.compiler.release>
    <picocli.version>4.7.7</picocli.version>
    <project.build.outputTimestamp>1980-02-01T00:00:00Z</project.build.outputTimestamp>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
  </properties>
  ...
  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
      <version>1.0.0</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <version>1.14.1</version>
      <scope>compile</scope>
    </dependency>
  </dependencies>
  ...
  <build>
    ...
    <plugins>
      <plugin>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.16.0</version>
        <executions>
          <execution>
            <id>default-compile</id>
            <phase>compile</phase>
            ...

Read it for these things: the dependency versions that were not in the module POM (resolved from dependencyManagement), the properties that came from three different places, the distributionManagement that came from the company parent, the plugin version pinned by pluginManagement, and the default-* executions that the packaging added (see Build Lifecycle). The paths in <build> (sourceDirectory, outputDirectory, …​) were written as absolute paths on the machine; they are the Super POM’s ${project.basedir}/src/main/java after interpolation. With -Dverbose=true (a parameter since Help plugin 3.2.0; 3.5.2 was used) the plugin adds a comment to every element with the POM and line that the value came from. The same run (Maven 3.10.0, shortened):

  <groupId>io.tidepool</groupId>  <!-- io.tidepool:tidepool-parent:1.0.0, line 14 -->
  <artifactId>tidepool-engine</artifactId>  <!-- io.tidepool:tidepool-engine:1.0.0, line 13 -->
  <version>1.0.0</version>  <!-- io.tidepool:tidepool-parent:1.0.0, line 16 -->
  <name>tidepool-engine</name>  <!-- io.tidepool:tidepool-engine:1.0.0, line 14 -->
  <description>Tide height prediction toolkit</description>  <!-- io.tidepool:tidepool-parent:1.0.0, line 20 -->
  <inceptionYear>2020</inceptionYear>  <!-- org.example.corp:example-corporate-parent:7, line 14 -->
  ...
    <junit.version>6.1.3</junit.version>  <!-- org.example.corp:example-corporate-parent:7, line 31 -->
    <maven.compiler.release>21</maven.compiler.release>  <!-- org.example.corp:example-corporate-parent:7, line 30 -->
    <picocli.version>4.7.7</picocli.version>  <!-- io.tidepool:tidepool-parent:1.0.0, line 36 -->
    <project.build.outputTimestamp>1980-02-01T00:00:00Z</project.build.outputTimestamp>  <!-- org.apache.maven:maven-model-builder:3.10.0:super-pom, line 30 -->

That is the fastest answer to "where does this value come from?": the Java release is on line 30 of the company parent, the version is on line 16 of the project parent, and the output timestamp is the Super POM’s. (Line numbers are those of the scratch files; url and scm values are shown with the appended artifact id and point to the lines of the parent that supplied them.)

The same kind of question exists for settings.xml, which also merges (the global settings, the user settings, and -s and -gs on the command line, see settings.xml & Password Encryption):

mvn -B -s settings-demo.xml help:effective-settings -Doutput=effective-settings.xml

With a scratch settings-demo.xml that declares a server corp-releases with ${env.REPO_USER} and ${env.REPO_PASSWORD}, and an active profile fast that sets skipITs, the result on Maven 3.10.0 was (header removed, home directory shortened):

<settings xmlns="http://maven.apache.org/SETTINGS/1.3.0" ...>
  <localRepository>/home/user/.m2/repository</localRepository>
  <interactiveMode>false</interactiveMode>
  <servers>
    <server>
      <username>${env.REPO_USER}</username>
      <password>***</password>
      <id>corp-releases</id>
    </server>
  </servers>
  <mirrors>
    <mirror>
      <mirrorOf>external:http:*</mirrorOf>
      <name>Pseudo repository to mirror external repositories initially using HTTP.</name>
      <url>http://0.0.0.0/</url>
      <blocked>true</blocked>
      <id>maven-default-http-blocker</id>
    </mirror>
  </mirrors>
  <profiles>
    <profile>
      <properties>
        <skipITs>true</skipITs>
      </properties>
      <id>fast</id>
    </profile>
  </profiles>
  <activeProfiles>
    <activeProfile>fast</activeProfile>
  </activeProfiles>
  <pluginGroups>
    <pluginGroup>org.apache.maven.plugins</pluginGroup>
    <pluginGroup>org.codehaus.mojo</pluginGroup>
  </pluginGroups>
</settings>

The password is masked as *** by the plugin, which makes the output safe to paste into a ticket. The mirror that blocks plain http:// repositories (HTTP repositories are blocked since Maven 3.8.1) comes from the global conf/settings.xml of the Maven installation, and the default plugin groups from the settings model; neither was in the scratch file, and this listing is the quickest way to see that a mirror exists when a download fails. The ${env.REPO_USER} stayed as it was because that variable was not set in the shell; the placeholders are expanded when the credentials are used. mvn help:active-profiles -pl tidepool-engine printed - fast (source: external) for the same file: the profile that was active for that project and where it was declared.

The effective POM does not say what the profiles that are inactive would add. For that, help:all-profiles lists them, and -P activates one (see Profiles). On the tidepool build help:all-profiles printed the heading and no profile, because the example has none. For a task such as "why is this plugin configured this way", combine the verbose effective POM with mvn -X, which prints the configuration that each mojo actually received (see Troubleshooting & Best Practices). The Maven 3.10 release notes mention that model problems found while building the project are available to plugins in the session, so that a plugin can reject a build that has model warnings; the Enforcer rules in The Enforcer Plugin are the usual place to do that.

Pitfalls

  • The parent is not in the reactor and not in a repository. The error is "Non-resolvable parent POM". Check that the relativePath leads to a POM whose coordinates match, or empty the element and install or deploy the parent.

  • The warning about parent.relativePath points at …​ means the sibling file is not the parent; Maven 3.9 and 3.10 then fall back to the repository, Maven 4 may stop (see above). Fix the path; do not ignore the warning.

  • A property or version that "does not take effect". Look at the verbose effective POM; a child, a profile or a command-line -D overrides the parent, and variables are resolved after inheritance.

  • A list that lost the parent’s entries. The default merge replaces lists; use combine.children="append".

  • A plugin that runs once per module and should run once. Set <inherited>false</inherited> on it in the aggregator, or move it to a module of its own.

  • scm.connection of a module that ends in .git/<module>. Set the scm attribute child.scm.connection.inherit.append.path="false" on the parent’s <scm> (not run here) or write the connection in each module that needs it.

  • Moving to 3.10 and losing the four plugin versions (antrun, assembly, dependency, release) or the release profile of the Super POM. Pin versions in your parent and define your own release profile.

  • Expecting <modules> to be inherited, or a parent to know its children. Neither is true.

  • A deep parent chain. Each level is another file to open when something is wrong; two levels (company, project) cover almost every case, and BOMs cover the rest.

The same idea in other tools

Both of the other tools share configuration across projects, with a different mechanism:

Need Maven Ant Gradle

Share configuration between projects

<parent> inheritance, single parent, merged by the model builder

<import> of a common build file or a shared build-common.xml, see Multi-File Builds: import, include & subant

convention plugins in a build-logic included build, see Convention Plugins & Sharing Build Logic

Build several projects together

<modules> aggregation and the reactor

subant / antcall over a list of build files

include(...) in settings.gradle.kts, see Multi Project Builds

See the resolved configuration

help:effective-pom, help:effective-settings

ant -diagnostics, ant -v

gradle properties, gradle dependencies, gradle help --task

The notable difference is that in Maven the parent relation is part of the project’s coordinates and of what is published, whereas a Gradle convention plugin or an Ant import is a build-time detail that consumers never see. The migration view is in Migrating Between Tools.