Maven 4: POM Model & Consumer 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 goes deeper than the overview in Maven 4: What’s New on the one part of Maven 4 that touches every project: the POM. Maven 4.0.0-rc-7 is a release candidate, not a final release, and no 4.0.0 had been published on 2026-10-16, when everything here was run: the schema, the consumer POM rules and the messages quoted below can still change. The Maven 4 examples ran on rc-7 on JDK 26 (the whole tidepool build was run once more on JDK 17.0.14, the minimum Java for Maven 4, with the identical consumer POM); the comparisons ran on Maven 3.10.0, the current Maven 3 release, and Maven 3.9.16. Plugin versions are the stable releases of 2026-10-16, except the Maven 4 generation of the compiler and resources plugins (4.0.0-beta-5, 4.0.0-beta-2), which the sources section needs. Each example lives in its own scratch directory with its own local repository; paths in console output were shortened to /work/..., the lines Loaded N auto-discovered prefixes ... that rc-7 prints on the first request to each repository were removed, and any other trimming is marked.

The overview already covers the reasons (the big idea), the one-line description of each feature, mvnup and the Maven 3 error you get from a 4.1.0 POM. This page covers what the overview leaves out: the schema difference element by element, the exact files Maven 4 writes, three ways to publish a consumer POM, and a set of traps measured on rc-7. For the older mechanics see POM Fundamentals, Inheritance, Aggregation & the Effective POM and Maven CI-Friendly Versions; the lifecycle changes are on Maven 4: Lifecycle and the upgrade procedure on Migrating to Maven 4.

Three POMs, not one

Maven 3 has two POM-like things: the file in your source tree and the effective POM Maven computes from it (inheritance, interpolation, profiles). The published file was just a copy of the first. Maven 4 adds a third and publishes that instead:

Name What it is Seen by

Build POM

The pom.xml in source control. May be model 4.0.0 or 4.1.0. Installed as name-version-build.pom, with the classifier build.

You, Maven 4 and your IDE

Effective POM

The in-memory model after inheritance, interpolation and profile activation. mvn help:effective-pom prints it. It is never written to a repository.

The build itself

Consumer POM

Generated at install and deploy time from the build POM, always model 4.0.0 unless a 4.1.0-only feature cannot be expressed. Installed and deployed as the plain name-version.pom.

Everyone who depends on the artifact: Maven 3, Maven 4, Gradle, scanners, Maven Central

flowchart TD SRC["pom.xml in source control: build POM, model 4.0.0 or 4.1.0"] SRC --> MB["Model builder: inheritance, interpolation, profile activation"] MB --> EFF["Effective POM: in memory, runs the build, never published"] SRC --> CP["Consumer POM generation: fill inferred values, resolve CI-friendly properties, optional flatten, downgrade to 4.0.0"] SRC --> BP["Build POM copy"] CP --> FILES["install and deploy"] BP --> FILES FILES --> R1["name-version.pom, the consumer POM, model 4.0.0"] FILES --> R2["name-version-build.pom, the build POM, classifier build"] R1 --> USERS["Maven 3, Maven 4, Gradle, scanners"]

The diagram is a summary of what the runs below show, not a description of Maven’s internal classes. Everything on the left of install and deploy is your business; everything on the right is a contract with people you will never meet, which is why the schema they read could not change.

Model version 4.1.0

A POM is a model 4.1.0 POM when the root element is in the namespace http://maven.apache.org/POM/4.1.0. The conventional header, which is what the Maven Upgrade Tool writes, is:

<project xmlns="http://maven.apache.org/POM/4.1.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.1.0 https://maven.apache.org/xsd/maven-4.1.0.xsd">

The schema URL is a redirect that follows the current release of Maven 4. On 2026-10-16 it answered:

$ curl -sIL https://maven.apache.org/xsd/maven-4.1.0.xsd | grep -iE "^(HTTP|location)"
HTTP/2 302
location: https://maven.apache.org/xsd/maven-4.1.0-rc-6.xsd
HTTP/2 200

(The target still carried the name of release candidate 6 while rc-7 was current, so it moves between releases: use maven-4.1.0.xsd in your POMs, not the target of the redirect.) The file is for IDEs and schema-aware editors.

The element modelVersion becomes optional

In a 4.1.0 POM the <modelVersion> element can be left out, because the namespace already says it. The upgrade tool removes it. The consequence is easy to miss on a team that still has a Maven 3 user: Maven 3 does not know the new namespace, finds no modelVersion, and reports that, not the real cause. This is the same tidepool parent POM with the root attribute taken out, built with Maven 3.10.0:

$ mvn -q validate
[ERROR] [ERROR] Some problems were encountered while processing the POMs:
[ERROR] 'modelVersion' is missing. @ io.tidepool:tidepool-parent:${revision}, /work/noroot/pom.xml, line 4, column 110
[ERROR] 'modelVersion' is missing. @ io.tidepool:tidepool-parent:${revision}, /work/noroot/pom.xml, line 4, column 110
 @

With the root="true" attribute present the first message is the one the overview shows, Unknown attribute 'root' for tag 'project', and Maven 3.9.16 prints the same lines. Either way the rule is the one the overview gives: a repository with a 4.1.0 build POM can only be built with Maven 4.

What the schema adds, deprecates and removes

Comparing maven-4.0.0.xsd with the 4.1.0 schema that the redirect above points to, type by type, for every element and attribute name that appears or disappears, gives the complete list below. No other element or attribute name differs, so dependencyManagement, profiles, repositories or distributionManagement, for example, are unchanged.

Where New in 4.1.0 Notes

<project> attributes

root, preserve.model.version

root defaults to false: this POM is the root of the source tree (The root directory, in practice). preserve.model.version is explained in The 4.1.0 consumer POM and preserve.model.version.

<project> and <profile>

<subprojects> with <subproject>

Replaces <modules>, which is now deprecated (Subprojects in detail).

<build>

<sources> with <source>

Replaces <sourceDirectory>, <testSourceDirectory>, <scriptSourceDirectory>, <resources> and <testResources>, which the schema marks as deprecated (Declaring sources).

<activation>

<condition> and <packaging>

<condition> is an expression; <packaging> activates a profile on the project’s packaging. See the profile section of the overview and Maven Profiles.

<extension> (in <build><extensions>)

<configuration>

Lets a build extension take configuration.

Two further 4.1.0 features are behaviour, not new names: the bom packaging (build POM only; the consumer POM says pom) and the use of <classifier> and <exclusions> on an import-scope BOM dependency, both described in the overview and in Dependency Management & BOMs.

Two elements disappear from the schema: <reports> (in <project> and in <profile>, long deprecated in favour of <reporting>) and <goals> inside <plugin>. A POM that still uses them is no longer valid 4.1.0, which is one more reason to move to 4.1.0 deliberately and not by search and replace. The <source> element is a whole new type; its fields are in Declaring sources.

Converting tidepool

The conversion of the tidepool project is mostly mechanical, so it is a good fit for mvnup. The starting point is a Maven 3 style 4.0.0 build with a ${revision} version, three <module> entries and repeated parent and dependency coordinates. It builds on Maven 3.10.0 and on Maven 4.0.0-rc-7 without change; on rc-7 it only adds the warning that no root directory was found (The root directory, in practice). mvnup apply with --model-version 4.1.0 --infer, in a scratch git repository, reported:

$ mvnup -d . apply --model-version 4.1.0 --infer -B
[INFO] Executing 3 upgrade strategy(ies):
[INFO]   - Upgrading POM model version
[INFO]   - Applying Maven inference optimizations
[INFO]   - Migrating source configuration to <source> elements
...
[INFO]         • Updated modelVersion to 4.1.0
[INFO]         • Updated namespace to http://maven.apache.org/POM/4.1.0
[INFO]         • Updated xsi:schemaLocation
[INFO]         • Converted <modules> to <subprojects>
[INFO]         • Converted 3 <module> elements to <subproject>
...
[INFO]       • Removed: redundant subprojects list (matches direct children)
[INFO]       • Removed: modelVersion element (can be inferred from namespace)
...
[INFO]       • Removed: parent groupId (child has no explicit groupId)
[INFO]       • Removed: parent version (child has no explicit version)
[INFO]       • Removed: parent artifactId (can be inferred from relativePath)
...
[INFO]       • Migrated property: maven.compiler.release = 17
[INFO]       • Set targetVersion: 17
...
[INFO] Creating .mvn directory if needed to avoid root directory warnings...

(Only the headline lines of each strategy are shown.) Three details are worth knowing. The tool deletes the list of subprojects when it equals the direct children, trusting discovery (Subprojects in detail). It does not touch a dependency such as <version>${project.version}</version>, so the version is still repeated in the POMs of tidepool-engine and tidepool-cli. And it moves maven.compiler.release into <sources>, which broke the build with the 3.x compiler plugin, a trap that the overview already measured (the compiler failed with records are not supported in -source 8, because 3.x ignores targetVersion); with the 4.x compiler plugin (4.0.0-beta-5) the same converted project compiled for release 17, and the class file major version was 61. On a copy without a .mvn directory the tool also printed Created .mvn directory (an empty one), so it silences the missing-root warning for you. I finished the conversion by hand: I restored the property, removed the dependency versions and used the root attribute instead of the empty directory. The result, which the rest of the page uses, is:

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

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-parent</artifactId>
  <version>${revision}</version>
  <packaging>pom</packaging>

  <properties>
    <revision>1.0.0-SNAPSHOT</revision>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <!-- build/pluginManagement with the plugin versions pinned (compiler 3.16.0, jar 3.5.1, ...) -->
</project>

and each subproject, for example tidepool-engine, is eleven lines with no coordinates but its own artifact id:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.1.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.1.0 https://maven.apache.org/xsd/maven-4.1.0.xsd">
  <parent/>
  <artifactId>tidepool-engine</artifactId>
  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
    </dependency>
  </dependencies>
</project>

Subprojects in detail

<subprojects> is <modules> renamed, with the same meaning: a list of relative paths to directories. The overview shows discovery; the rules that remain are these, all run on rc-7.

The old element still works, with a warning. A 4.1.0 POM that keeps <modules> builds, and the warning only shows its text with -e:

$ mvn -e validate
[INFO] Error stacktraces are turned on.
[INFO] Scanning for projects...
[WARNING]
[WARNING] 1 problem was encountered while building the effective model for 'io.tidepool:tidepool-parent:1.0.0-SNAPSHOT' (use -e to see details)
[WARNING] 'modules' deprecated modules element, use subprojects instead
[WARNING]
...

Both elements in one POM is an error, and so is the new element in an old POM. With <modules> and <subprojects> together in a 4.1.0 POM:

[ERROR] Encountered 1 problem(s) while processing the POMs
[ERROR] The build could not read 1 project -> [Help 1]
[ERROR]
[ERROR]   The project io.tidepool:tidepool-parent:pom:1.0.0-SNAPSHOT (/work/mix/pom.xml) has 1 error
[ERROR]     'modules' cannot use both modules and subprojects element @ io.tidepool:tidepool-parent:1.0.0-SNAPSHOT, /work/mix/pom.xml

and with <subprojects> in a POM whose namespace is still 4.0.0:

[ERROR]   The project io.tidepool:tidepool-parent:pom:1.0.0-SNAPSHOT (/work/sub40/pom.xml) has 1 error
[ERROR]     'subprojects' unexpected subprojects element @ io.tidepool:tidepool-parent:1.0.0-SNAPSHOT, /work/sub40/pom.xml

Discovery has three conditions. The official rule is: pom packaging, no <subprojects> or <modules> element, and a pom.xml in a direct subdirectory. Write the list when you want only some of the directories. The ordering rules (dependencies, plugins, extensions and then the declared order) are in the Maven 4 multi-project guide and in Multi-Module Builds & the Reactor.

A single subproject can be built from its own directory or with -f. Maven walks up to find the root (a .mvn directory or a POM with root="true"), so the dependency on tidepool-model, which has no version in the engine’s POM, can be resolved wherever you start. Both of these succeeded, with the model already installed in the local repository:

$ mvn -f tidepool-engine/pom.xml -q validate
$ mvn -pl tidepool-engine validate
[INFO] Building tidepool-engine 1.0.0-SNAPSHOT
[INFO] BUILD SUCCESS

(The first command prints nothing on success because of -q.) Without the parent on disk, the shorthand fails with a message about the parent, not about the inference: a copy of the engine POM placed in a lone directory gave

[ERROR]   The project (/work/orphan/pom.xml) has 3 errors
[ERROR]     'parent.groupId' is missing. @ [unknown-group-id]:tidepool-engine:[unknown-version], /work/orphan/pom.xml, line 5, column 3
[ERROR]     'parent.artifactId' is missing. @ [unknown-group-id]:tidepool-engine:[unknown-version], /work/orphan/pom.xml, line 5, column 3
[ERROR]     Failed to load project /work/orphan/pom.xml: 2 problems were encountered while building the effective model

(Three of the lines were removed.) The practical consequence: a subproject POM copied out of its tree, into an archetype or an email, no longer describes itself.

The root directory, in practice

The overview lists the attribute and the three properties. Here is what they evaluate to. Three runs of the Maven Help Plugin’s evaluate goal gave the values below: inside tidepool-cli, from the root with -f tidepool-cli/pom.xml, and at the root with -N. The plugin wraps path values in a <path> element and, on JDK 26, prints four warnings about sun.misc.Unsafe from a library it uses; both were removed from the table.

Expression cd tidepool-cli -f tidepool-cli/pom.xml from the root At the root with -N

${basedir}

/work/d/tidepool-cli

/work/d/tidepool-cli

/work/d

${project.rootDirectory}

/work/d

/work/d

/work/d

${session.rootDirectory}

/work/d

/work/d

/work/d

${session.topDirectory}

/work/d/tidepool-cli

/work/d/tidepool-cli

/work/d

The command for each cell was mvn org.apache.maven.plugins:maven-help-plugin:3.5.2:evaluate -Dexpression=NAME -q -DforceStdout. The root properties are the same wherever you start; the top directory follows the POM you started from. Use ${session.rootDirectory} or ${project.rootDirectory} for files shared by the whole tree (a Checkstyle suppression file, a license header) and ${basedir} for files of one subproject. The overview’s table says the root properties are not always set: define a root in every tree that uses them.

Maven creates the directory for you, once. A 4.0.0 build with no .mvn and no attribute prints Unable to find the root directory ... on the first Maven 4 run, and that run itself creates .mvn/target/project-local-repo, so the second run finds a .mvn directory and the warning is gone:

$ mvn verify          # first run
[WARNING] Unable to find the root directory. Create a .mvn directory in the root directory or add the root="true" attribute on the root project's model to identify it.
[INFO] BUILD SUCCESS
$ mvn verify          # second run, same directory
[INFO] BUILD SUCCESS

(Only the warning and result lines are shown.) Do not count on that: the directory is a build artifact, and a fresh clone warns again.

A side effect to put in .gitignore. Once a root is known, Maven 4 keeps .mvn/target/project-local-repo in it, even for mvn verify with no install, and puts there, per project, the jar, a copy of the build POM as name-version.pom and the generated name-version-consumer.pom; it looks like the area where the reactor stages the consumer form of each project. mvn clean does not remove it, because the directory is outside the subproject’s target:

.mvn/target/project-local-repo/io.tidepool/tidepool-cli/1.0.0-SNAPSHOT/tidepool-cli-1.0.0-SNAPSHOT-consumer.pom
.mvn/target/project-local-repo/io.tidepool/tidepool-cli/1.0.0-SNAPSHOT/tidepool-cli-1.0.0-SNAPSHOT.pom
.mvn/target/project-local-repo/io.tidepool/tidepool-cli/1.0.0-SNAPSHOT/tidepool-cli-1.0.0-SNAPSHOT.jar

(One of the four projects is shown.) Add .mvn/target/ to the ignore file next to target/; the other files that may live in .mvn (maven.config, jvm.config, extensions.xml, maven-user.properties) are meant to be committed.

Inference rules

Parent inference and version inference only exist in a model 4.1.0 POM and are applied while the POM is read, before the consumer POM is generated. Observed on rc-7:

  • An empty <parent/> means <relativePath>..</relativePath>. A <parent> with only a <relativePath> takes group, artifact and version from the pom.xml found there.

  • <groupId> and <version> of a subproject default to the parent’s, as before; with inference you also leave out the parent’s coordinates.

  • A dependency whose group and artifact id name another project of the same build needs no <version>. It is the version of that project, which is why the engine can list the model without one.

  • Inference does not touch a version such as ${project.version} that you typed: Maven 4 builds a 4.0.0 project with that expression and publishes it unchanged. In the 4.0.0 tidepool-engine the consumer POM (built by rc-7) still contains <version>${project.version}</version> in the dependency, but the parent version was resolved to 1.0.0-SNAPSHOT and the <relativePath> removed. The expression is correct for consumers because it is evaluated against the project’s own version.

Inference is a convenience of the build POM. The consumer POM that is published always spells the coordinates out, so a Maven 3 or Gradle user never has to implement it.

Declaring sources

The overview shows two source roots and the 4.x compiler plugin’s need for a root directory. The <source> element has more fields. They come from the 4.1.0 schema, so a plugin must read them to honour them ([sources-plugin-support]):

Element Meaning

<directory>

Path relative to the POM. Repeating <source> gives several directories.

<scope>

main (default) or test.

<lang>

java (default) or resources. resources also covers scripts and any non-compiled file, and replaces <resources> and <testResources>.

<module>

The Java module a directory belongs to, for projects with several modules in one build.

<targetVersion>

The Java release (--release) to compile that directory for. Several main directories with different values give a multi-release JAR, with the lowest as the base.

<targetPath>

Output directory; the default is target/classes for main, target/test-classes for test.

<stringFiltering>

true to filter ${...} tokens in a resources source (default false).

<enabled>

true (default) or false: whether to use the directory at all.

<includes> and <excludes>

Ant-style patterns.

A scratch project with the four kinds of source (a main Java directory, an optional extra one, a filtered resources directory and a test directory), built with clean test on rc-7 with the compiler plugin 4.0.0-beta-5 and the resources plugin 4.0.0-beta-2:

<build>
  <sources>
    <source>
      <scope>main</scope>
      <lang>java</lang>
      <directory>src/main/java</directory>
    </source>
    <source>
      <scope>main</scope>
      <lang>java</lang>
      <directory>src/extra/java</directory>
      <enabled>true</enabled>
    </source>
    <source>
      <scope>main</scope>
      <lang>resources</lang>
      <directory>src/main/conf</directory>
      <stringFiltering>true</stringFiltering>
    </source>
    <source>
      <scope>test</scope>
      <lang>java</lang>
      <directory>src/test/java</directory>
    </source>
  </sources>
</build>

The class files and resources that ended in target, and the filtered file (the source line is app.name=${project.artifactId}):

classes/app.properties
classes/p/A.class
classes/p/Extra.class
test-classes/p/ATest.class

$ cat target/classes/app.properties
app.name=srcs

With <enabled>false</enabled> the same build produced no Extra.class; that is the field to use for an optional directory.

The effective POM shows how Maven 4 keeps the old API working for plugins that do not know <sources>: the legacy elements are derived from the new ones, and defaults (main, java) are omitted. This is help:effective-pom for that project, excerpted:

<sources>
  <source>
    <directory>/work/srcs/src/main/java</directory>
  </source>
  <source>
    <lang>resources</lang>
    <stringFiltering>true</stringFiltering>
    <directory>/work/srcs/src/main/conf</directory>
  </source>
  <source>
    <scope>test</scope>
    <directory>/work/srcs/src/test/java</directory>
  </source>
</sources>
<sourceDirectory>/work/srcs/src/main/java</sourceDirectory>
<testSourceDirectory>/work/srcs/src/test/java</testSourceDirectory>
<resources>
  <resource>
    <filtering>true</filtering>
    <directory>/work/srcs/src/main/conf</directory>
  </resource>
</resources>

(The extra directory, scriptSourceDirectory and testResources entries were removed for brevity.) This is also why the 3.x resources plugin 3.5.0 copied and filtered src/main/conf correctly in a run on rc-7: it reads <resources>. The 3.x compiler plugin line compiled only src/main/java, the single legacy source directory.

Three traps measured on rc-7:

  • <enabled> is not interpolated. With <enabled>${with.extra}</enabled> and <with.extra>true</with.extra> in the properties, the effective POM printed <enabled>false</enabled> for the extra directory and Extra.class was missing, both with the property in the POM and with -Dwith.extra=true. Write the literal value; to switch a directory with a profile, put the <source> inside the profile.

  • The consumer POM loses the whole <build>. When the build POM has <sources>, the 4.0.0 consumer POM written by rc-7 has no <build> element at all, plugins included; the same project without <sources> keeps its plugin list. Nothing a consumer needs is lost, but do not rely on plugin information in a published POM (Default: the build POM, resolved).

  • Plugin support is uneven. The overview names the compiler, install, deploy and resources lines that use the new API. A plugin that reads only the legacy elements keeps working because of the derivation above, and ignores targetVersion, module and targetPath.

The consumer POM, measured

Everything in this section was produced by installing the example into an empty local repository and comparing files. The project is a parent with a 4.1.0 child that has one dependency of each scope, a property, a BOM import in the parent and a plugin configuration:

<project xmlns="http://maven.apache.org/POM/4.1.0" ...>
  <parent/>
  <artifactId>flat-app</artifactId>
  <name>Flat App</name>
  <properties>
    <only.in.build.pom>yes</only.in.build.pom>
  </properties>
  <dependencies>
    <dependency>                                   <!-- version from the parent's dependencyManagement -->
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
    </dependency>
    <dependency>                                   <!-- runtime -->
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-simple</artifactId>
      <version>2.0.17</version>
      <scope>runtime</scope>
    </dependency>
    <dependency>                                   <!-- provided -->
      <groupId>jakarta.servlet</groupId>
      <artifactId>jakarta.servlet-api</artifactId>
      <version>6.1.0</version>
      <scope>provided</scope>
    </dependency>
    <dependency>                                   <!-- test, version from the JUnit BOM imported in the parent -->
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <artifactId>maven-jar-plugin</artifactId>
        <configuration>
          <archive><manifestEntries><Built-By-Pom>flat-app</Built-By-Pom></manifestEntries></archive>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

(The parent is a pom project with root="true", the version 1.0.0, a commons.version property, the JUnit BOM import and pinned plugin versions; the xmlns header lines were shortened.) This POM is this page’s own lab setup, not an example from the Maven documentation.

Default: the build POM, resolved

mvn install installed flat-app-1.0.0.jar, flat-app-1.0.0.pom and flat-app-1.0.0-build.pom. The default consumer POM is not stripped of build information in rc-7. This is the file, with the unchanged <dependencies> and <build> left out:

<?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>
  <parent>
    <groupId>io.tidepool</groupId>
    <artifactId>flat-parent</artifactId>
    <version>1.0.0</version>
  </parent>
  <artifactId>flat-app</artifactId>
  <name>Flat App</name>
  <description>Shows what the consumer POM keeps and drops.</description>
  <properties>
    <only.in.build.pom>yes</only.in.build.pom>
  </properties>
  <dependencies>
  ...
  </dependencies>
  <build>
  ...
  </build>
</project>

What changed compared with the build POM is only the form: the model version is 4.0.0, the <parent/> shorthand is a real parent, <relativePath> is gone, and everything else, the property, the four dependencies with their scopes (the managed versions are still omitted: the parent supplies them), and the jar plugin configuration, was copied. The file is also reformatted (the source layout is not kept). The parent is installed too, because the consumer POM refers to it. The official comparison table says that plugin configuration and properties exist only in the build POM and that the consumer POM "might" lose more build data in future; on rc-7 they were kept, so treat the published POM as containing build information and avoid putting anything sensitive in the build section.

A pom-packaging parent is treated the same way: installing tidepool-parent kept <properties> and <pluginManagement>, which children need, dropped the <modules> list (and the root attribute of the 4.1.0 form) and replaced ${revision} by its value.

Flattened: the minimal consumer POM

Set maven.consumer.pom.flatten to true and the consumer POM becomes self-contained. This is the same build with -Dmaven.consumer.pom.flatten=true:

<?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>io.tidepool</groupId>
  <artifactId>flat-app</artifactId>
  <version>1.0.0</version>
  <name>Flat App</name>
  <description>Shows what the consumer POM keeps and drops.</description>
  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.20.0</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-simple</artifactId>
      <version>2.0.17</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>
Part of the project Build POM Default consumer POM Flattened consumer POM

Model version

4.1.0

4.0.0

4.0.0

<parent>

<parent/>

Kept, version filled in

Removed; groupId and version copied into the project

Properties

Yes

Yes

No

Plugins and their configuration

Yes

Yes (unless the build POM has <sources>)

No

compile and runtime dependencies

Yes

Yes, managed versions omitted

Yes, with the managed version and the compile scope written out

provided and test dependencies

Yes

Yes

No

name, description and other project information

Yes

Yes

Yes

The dropped scopes (provided and test) are not transitive anyway, so what flattening mainly removes is the need to resolve the parent and the imported BOM to read the dependencies. The article gives the reason it is off by default: to avoid unexpected behaviour and to keep control of how (transitive) dependencies are listed. The parent POM of a flattened child is still installed, since it is a normal project of the build.

Switching it on: command line or .mvn/maven-user.properties

The property is a user property, so -D works. The official article names a second place, which is the one to prefer in a repository because it applies to every contributor and to CI, with no change to the command line. Putting a single line in .mvn/maven-user.properties:

maven.consumer.pom.flatten=true

and running a plain mvn clean install gave a consumer POM with no <parent> and only the compile and runtime scopes, exactly like the -D run. It also took effect when the build was started from another directory with -f flat2/app/pom.xml: that consumer POM again had no <parent> and no <properties>. I found no pom.xml element that does the same; the switch is a property, because it is about how you publish, not about what the project is.

The 4.1.0 consumer POM and preserve.model.version

The consumer POM is 4.0.0 only as long as everything in the build POM can be written in 4.0.0. A condition-based profile cannot. A scratch project with this profile (the build POM has no other 4.1.0 feature):

<profiles>
  <profile>
    <id>jdk-17-plus</id>
    <activation>
      <condition>inrange(${java.specification.version}, "[17,)")</condition>
    </activation>
    <dependencies>
      <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.20.0</version>
      </dependency>
    </dependencies>
  </profile>
</profiles>

produced a consumer POM with <modelVersion>4.1.0</modelVersion> and the profile untouched. The overview says that Maven warns when this happens; on rc-7 I saw no warning in the build output, only the Installing .../consumer-NNN.pom line, so check the installed file. A downstream project whose only dependency is this artifact (version 1.0.0) shows why it matters, because commons-lang3 lives only in the profile. The dependency tree, on Maven 3.10.0 and on Maven 4.0.0-rc-7:

$ mvn org.apache.maven.plugins:maven-dependency-plugin:3.11.0:tree     # Maven 3.10.0
[INFO] io.example:downstream:jar:1.0
[INFO] \- io.tidepool:cp:jar:1.0.0:compile

$ mvn org.apache.maven.plugins:maven-dependency-plugin:3.11.0:tree     # Maven 4.0.0-rc-7
[INFO] io.example:downstream:jar:1.0
[INFO] \- io.tidepool:cp:jar:1.0.0:compile
[INFO]    \- org.apache.commons:commons-lang3:jar:3.20.0:compile

Maven 3.10.0 and 3.9.16 both read the 4.1.0 consumer POM without a message and silently drop the profile and with it the dependency. A library that publishes for Maven 3 users must therefore avoid <condition>, or use the classic <jdk> activation, which 4.0.0 can express.

The preserve.model.version attribute decides whether the model version of the build POM is passed on. On the project without the profile, preserve.model.version="false" (the default) produced a 4.0.0 consumer POM, and preserve.model.version="true" produced one with <modelVersion>4.1.0</modelVersion> and the 4.1.0 namespace. In both cases name-1.0.0-build.pom was the 4.1.0 source file, with the attribute itself kept. Use true only when you want the published POM to be 4.1.0; as the dependency trees above show, Maven 3 still reads such a file, but it ignores what it does not know.

What Maven 3 consumers see

The point of the whole mechanism is that a project built with Maven 4 stays usable from Maven 3. A downstream Maven 3.10.0 project whose only dependency is tidepool-cli, installed by rc-7 from the 4.1.0 tidepool build:

$ mvn org.apache.maven.plugins:maven-dependency-plugin:3.11.0:tree
[INFO] io.example:downstream:jar:1.0
[INFO] \- io.tidepool:tidepool-cli:jar:1.0.0-SNAPSHOT:compile
[INFO]    \- io.tidepool:tidepool-engine:jar:1.0.0-SNAPSHOT:compile
[INFO]       \- io.tidepool:tidepool-model:jar:1.0.0-SNAPSHOT:compile

The consumer POM of tidepool-cli, 16 lines against the 13 of the build POM and the 248 of help:effective-pom for the same project, is:

<?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>
  <parent>
    <groupId>io.tidepool</groupId>
    <artifactId>tidepool-parent</artifactId>
    <version>1.0.0-SNAPSHOT</version>
  </parent>
  <artifactId>tidepool-cli</artifactId>
  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-engine</artifactId>
      <version>1.0.0-SNAPSHOT</version>
    </dependency>
  </dependencies>
</project>

(The effective POM is much longer because it carries the inherited properties, the 15 plugins from the lifecycle bindings and from pluginManagement, and the Super POM defaults; it exists only in memory.)

Install and deploy write both files

The official article says Maven 4 "does not deploy the pom.xml used to build the project". On rc-7 it deploys both. Deploying the 4.1.0 tidepool build to a file: repository with -DaltDeploymentRepository=local::file:/work/repo (the deploy plugin 3.2.0, as pinned in the parent; the real remote was not used) produced, for tidepool-cli:

io/tidepool/tidepool-cli/1.0.0-SNAPSHOT/tidepool-cli-1.0.0-20261016.145311-1-build.pom
io/tidepool/tidepool-cli/1.0.0-SNAPSHOT/tidepool-cli-1.0.0-20261016.145311-1.jar
io/tidepool/tidepool-cli/1.0.0-SNAPSHOT/tidepool-cli-1.0.0-20261016.145311-1.pom
io/tidepool/tidepool-cli/1.0.0-SNAPSHOT/maven-metadata.xml
io/tidepool/tidepool-cli/maven-metadata.xml
...

(The .md5 and .sha1 files of each entry were left out.) So the build POM, with the build classifier, is published next to the consumer POM and a repository manager sees an artifact it has not seen before. Plan for it in the policies of your repository manager (see Install, Deploy & Distribution Management and Repositories, Mirrors & Repository Managers). Publishing to Maven Central was not run here; its validation of the POM is on Publishing to Maven Central.

CI-friendly versions and the consumer POM

This is the case the consumer POM was built for. The tidepool build in both forms, built with -Drevision=2.0.0 and no flatten plugin: Maven 3.10.0 installed the child with the unresolved placeholder in its parent, Maven 4.0.0-rc-7 installed the resolved coordinates, including those of the dependency:

# Maven 3.10.0, the 4.0.0 tidepool build
  <parent>
    <groupId>io.tidepool</groupId>
    <artifactId>tidepool-parent</artifactId>
    <version>${revision}</version>
    <relativePath>../pom.xml</relativePath>
  </parent>

# Maven 4.0.0-rc-7, the 4.1.0 tidepool build (tidepool-cli consumer POM)
  <parent>
    <groupId>io.tidepool</groupId>
    <artifactId>tidepool-parent</artifactId>
    <version>2.0.0</version>
  </parent>
  ...
      <artifactId>tidepool-engine</artifactId>
      <version>2.0.0</version>

On rc-7 the installed parent POM also had <revision>2.0.0</revision> in its properties. The full recipe, including changelist, sha1 and the pipelines that set them, is on Maven CI-Friendly Versions.

Checklist before moving a project to 4.1.0

  • Every contributor and CI image can run Maven 4 (Java 17 to run it). A 4.1.0 build POM cannot be read by Maven 3, and the first message it prints is about root or modelVersion, not about the version.

  • A root exists: root="true" in the top POM, or a .mvn directory. The 4.x compiler plugin needs it, the directory properties need it, and Maven warns without it.

  • mvnup apply was reviewed in a diff: maven.compiler.release moved into <sources> (broken for the 3.x compiler plugin), subproject lists were deleted, ${project.version} dependencies were left.

  • No <reports> and no <goals> inside <plugin>, which 4.1.0 no longer defines.

  • No <condition> profile in a library with Maven 3 users, or you accept that they silently lose its dependencies.

  • The published consumer POM was inspected (~/.m2 or a file: repository) once per release candidate of Maven 4: the rules above are rc-7 behaviour, and the flatten default and the -build.pom classifier are the likeliest to change.

  • .mvn/target/ is ignored by source control.

Equivalents in Ant and Gradle

Gradle already separates the build from what consumers read. The build script is never published; a publication generates a POM (and an Ivy descriptor if asked) from a model you configure, and by default Gradle publishes its own metadata file next to it, which Gradle consumers prefer. The Maven 4 consumer POM brings Maven to the same position, with the difference that the build POM is still deployed. See Publishing for Gradle and Repositories & Publishing across Tools for the side-by-side. Apache Ant has no project model, so there is no build-versus-consumer split to compare; Ivy writes an ivy.xml that is the only descriptor, see Comparing Ant, Maven & Gradle. Moving a project between the tools is covered on Migrating between Tools.