EFCore.ComplexIndexes.PostgreSQL 5.5.0

dotnet add package EFCore.ComplexIndexes.PostgreSQL --version 5.5.0
                    
NuGet\Install-Package EFCore.ComplexIndexes.PostgreSQL -Version 5.5.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="EFCore.ComplexIndexes.PostgreSQL" Version="5.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="EFCore.ComplexIndexes.PostgreSQL" Version="5.5.0" />
                    
Directory.Packages.props
<PackageReference Include="EFCore.ComplexIndexes.PostgreSQL" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add EFCore.ComplexIndexes.PostgreSQL --version 5.5.0
                    
#r "nuget: EFCore.ComplexIndexes.PostgreSQL, 5.5.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package EFCore.ComplexIndexes.PostgreSQL@5.5.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=EFCore.ComplexIndexes.PostgreSQL&version=5.5.0
                    
Install as a Cake Addin
#tool nuget:?package=EFCore.ComplexIndexes.PostgreSQL&version=5.5.0
                    
Install as a Cake Tool

EFCore.ComplexIndexes.PostgreSQL

On EF Core 11 or later? This package supports EF Core 10 only. From 5.5.0 it throws when you register it on any other major version. Its successor for EF Core 11 is a new package, CodoMetis.EFCore.SchemaKit, tracked in #37.

PostgreSQL index and constraint features for EFCore.ComplexIndexes, through Npgsql. This package brings the core package with it.

On top of the core package's complex-property, composite, unique and filtered indexes, you get:

  • Index methods. GIN, GiST, BRIN, SP-GiST and Hash, with operator classes, covering (INCLUDE) indexes, concurrent creation, nulls-distinct control, per-column collation and storage parameters (WITH (fillfactor=70)).
  • NULLS FIRST / NULLS LAST for each column.
  • Expression indexes, in raw SQL or as a typed LINQ lambda, on any entity, with complex properties or without.
  • JSON indexes. Index members of ToJson() complex properties with the same expressions Npgsql's queries use. Or index the whole document, or a complex collection, with a GIN index on the jsonb column.
  • Temporal UNIQUE … WITHOUT OVERLAPS constraints and temporal foreign keys (PostgreSQL 18).
  • Exclusion (EXCLUDE) constraints, a more general form of uniqueness that also takes a WHERE filter, on every supported version.

Setup

For migrations, most features need nothing beyond installing the package. Two need one call in your context's configuration: expression indexes and null ordering. EF Core's index operation has no place for them, so this package's SQL generator writes their SQL when the migration runs. The same call also covers EnsureCreated(), as the last row shows.

Feature Needs UseNpgsqlComplexIndexes()
Index methods, operator classes, INCLUDE, concurrent creation, nulls-distinct no
Temporal constraints and temporal foreign keys no (since 5.0.2)
Exclusion constraints no
Expression indexes (raw SQL, typed LINQ, JSON member) yes
DbOrder.NullsFirst / NullsLast yes
EnsureCreated() / GenerateCreateScript() including the declarations yes (since 5.1.0)
services.AddDbContext<AppDbContext>(options =>
    options
        .UseNpgsql(connectionString)
        .UseNpgsqlComplexIndexes());

If you forget the call, the migration fails rather than creating an index that is quietly wrong. These indexes carry a placeholder column named __requires_UseNpgsqlComplexIndexes__. Npgsql's own generator writes it into the CREATE INDEX, and the migration fails when it runs, with that name in the error message.

Since 5.1.0 the same call also covers Database.EnsureCreated() and GenerateCreateScript(), so they create your complex indexes and constraints. The pending-model-changes check in Migrate() then also notices a complex index that no migration has created yet. These use your context's own services, which the automatic design-time registration never reaches. Without the call, EnsureCreated() creates the tables and none of the indexes, with no error.

Building your own internal service provider? Register the package on your service collection instead:

var provider = new ServiceCollection()
    .AddEntityFrameworkNpgsql()
    .AddNpgsqlComplexIndexes()
    .BuildServiceProvider();

Usage

Index methods and options

builder.ComplexProperty(x => x.Payload, c =>
    c.Property(x => x.Json)
     .HasComplexIndex(idx => idx.UseGin().HasOperators("jsonb_path_ops"))
);

The builder has UseGin(), UseGist(), UseBrin(), UseHash(), UseSpGist(), HasOperators(...), IncludeProperties(...), IsCreatedConcurrently() and AreNullsDistinct(...).

Null ordering

builder.HasComplexCompositeIndex(
    x => new { x.Name, Reviewed = DbOrder.NullsLast(DbOrder.Desc(x.ReviewedAt)) });
// CREATE INDEX ... (name, reviewed_at DESC NULLS LAST);

Expression indexes

The package writes raw SQL exactly as you give it. It doesn't map properties to columns and doesn't add quotes:

builder.HasExpressionIndex("lower(email)", isUnique: true, filter: "deleted_at IS NULL");

builder.HasExpressionIndex(idx => idx
    .Expression("country")
    .Expression("lower(email)").Descending()
    .UseGin()
    .HasName("ix_person_country_email_ci"));

Or pass a lambda. The package maps its property paths to columns in your finalized model, so it follows HasColumnName, complex-property columns and ToJson() members:

builder.HasExpressionIndex(x => x.Email.Value.ToLower(), isUnique: true);
// CREATE UNIQUE INDEX ... ON people ((lower("email")));

The lambda can use a small set of operations on purpose: ToLower/ToUpper, the Trim methods without arguments, Substring, Replace, string.Length, concatenation, ?? and constants. Anything else throws NotSupportedException where you declare the index, and the message points you to the raw-SQL overload. A value object stored through a converter maps through its member: x.Email.Value is the email column.

JSON member indexes

The members of a complex property mapped with ToJson() have no table columns. You declare their indexes the same way, and the package indexes an expression that reads the member:

builder.ComplexProperty(x => x.Name, c => c.ToJson("name"));
builder.HasComplexIndex(x => x.Name.ShortName, isUnique: true, indexName: "ux_employer_short_name");
// CREATE UNIQUE INDEX "ux_employer_short_name" ON employers (("name" ->> 'ShortName'));

Each member is read the way Npgsql's queries read it, so those queries use the index. A nested member is read with #>> '{Address,City}', a typed member is cast to its store type (CAST("profile" ->> 'Rank' AS integer)), and HasJsonPropertyName is followed.

Date and time members stay text, because PostgreSQL can't index their cast. A unique index on one still keeps the values unique. A non-unique index that starts with one makes migrations add fail. If you upgrade from 5.3 or earlier, the first migration re-creates the affected indexes once, under their existing names.

Temporal constraints — PostgreSQL 18

builder.HasTemporalConstraint(keyColumns: b => b.RoomId, period: b => b.ValidPeriod);
// ALTER TABLE bookings ADD CONSTRAINT ... UNIQUE (room_id, valid_period WITHOUT OVERLAPS);

The period must be a range or multirange column (daterange, tstzrange, NpgsqlRange<T>, …). Any other type makes migrations add throw. The period stays a plain mapped column, and on purpose it is not part of an EF key: EF Core doesn't allow range types in keys, since they can't be compared. For temporal foreign keys, use HasTemporalForeignKey. It needs a matching temporal constraint on the principal.

Exclusion constraints

An exclusion constraint is a more general kind of uniqueness. Unlike UNIQUE … WITHOUT OVERLAPS, it takes a WHERE filter, so it is the only way to keep periods from overlapping for some rows:

builder.HasExclusionConstraint(
    equalityColumns: x => new { x.GranteeId, x.RoleId },
    overlapsColumn:  x => x.Period,
    filter:          "revoked_at IS NULL",
    name:            "ex_role_grant_active_period");

Two declarations are the same constraint when they have the same elements in the same order and the same filter. So the same columns with different filters give you two constraints side by side, each over its own rows. Name both.

Filters, here and on indexes, can name properties as {Property.Path} placeholders. migrations add turns them into columns or JSON reads. Every filter: also has a typed form: x => x.RevokedAt == null, or HasFilter(x => …) on the builders (HasFilter<TEntity> on the non-generic index builders). The typed form supports a small set: null checks, comparisons, &&/||/!, and the string functions above. Where you declare it, it throws on an enum comparison, and on a value with no portable SQL spelling, such as a DateTime or a Guid.

You can read declared constraints back with GetExclusionConstraints() on an entity type or the model, or FindExclusionConstraint(name). You get the elements, method, filter, deferrability and name, from the mutable model in OnModelCreating as well as the finalized one. You can amend them there too: AddExclusionConstraintFilter(predicate, where) adds a predicate with AND to the filter of every constraint you select, and repeating the call changes nothing.

btree_gist

A scalar element in a gist constraint needs the btree_gist extension, whatever its operator. The package adds CREATE EXTENSION IF NOT EXISTS btree_gist to the migration for you. To manage it yourself, call modelBuilder.UseBtreeGist(). To leave it out, call SuppressTemporalExtensionAutoInjection().


Documentation

Changelog

This package's changelog, or the root changelog covering all three packages.

MIT licensed.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
5.5.0 55 10/20/2026
5.4.1 1,708 9/22/2026
5.4.0 1,676 9/22/2026
5.3.0 255 9/5/2026
5.2.0 162 9/5/2026
5.1.0 135 9/5/2026
5.0.3 412 8/15/2026
5.0.2 128 8/15/2026
5.0.1 147 8/10/2026
5.0.0 120 8/10/2026
4.0.0 2,603 6/14/2026
3.1.5 180 6/5/2026
3.1.0 149 6/4/2026
3.0.0 134 6/3/2026
2.0.5 387 5/14/2026
2.0.0 2,617 2/14/2026

The last 5.x minor with a change of its own: the line refuses EF Core 11, whose successor is CodoMetis.EFCore.SchemaKit.PostgreSQL.

Changed answers

- Indexes: an IncludeProperties(…) entry that names neither a property nor a column of the table fails at migrations add. When its property was removed, the migration dropped only the column, and the index with it, and applied cleanly (#142 (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/142), Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#include-entries-must-name-a-column)).
- Expression indexes: typed expression indexes and typed filters render Substring, Trim, TrimStart and TrimEnd as EF Core's queries do, so those queries can use the indexes. Trim() also trims tabs and line breaks, where it trimmed spaces only, so a typed filter can cover other rows: x.Code.Trim() != "" leaves out a tabs-only value (#72 (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/72), Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#substring-and-trim-render-as-ef-cores-queries)).
- Expression indexes: a typed expression index or filter wraps a concatenated operand that can be null in COALESCE(…, ''), as EF Core's queries do. x.Code + x.Suffix was NULL for every row without a suffix, so a unique index skipped those rows (#94 (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/94), Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#concatenation-with-a-nullable-operand)).
- Filters: a typed filter keeps C#'s answer where an operand is null. x => x.Kind != "federated" on a string? covers the rows whose Kind is null, where it left them out, and a captured null compares with IS NULL, where it matched no row (#68 (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/68), Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#typed-filters-keep-cs-answer-for-null-operands)).
- Filters: a brace in a string literal of a typed filter is compared as written. x => x.Code != "{draft}" renders '{draft}', where it rendered '{{draft}}' and kept the rows the filter excludes (#70 (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/70), Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#typed-filters-keep-braces-in-string-literals)).
- Constraints: an exclusion constraint, temporal constraint or temporal foreign key declared on a TPC type is added to the table of every type derived from it. On a concrete base it was added to the base's table only, and the derived tables accepted the overlapping rows it refuses (#93 (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/93), Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#declarations-on-a-tpc-type-cover-every-table-of-the-hierarchy)).

Breaking

- Registration: UseNpgsqlComplexIndexes(), AddNpgsqlComplexIndexes() and the design-time registration dotnet ef runs throw InvalidOperationException on an EF Core major other than 10, naming CodoMetis.EFCore.SchemaKit for EF Core 11. EF Core 10 on .NET 11 keeps working (Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#ef-core-11-is-refused-at-registration)).
- Indexes: a complex index, exclusion constraint or temporal constraint on a TPC type with derived types fails at migrations add when it reads a member of a complex property, which EF Core 10 maps to no column in a derived table; map the member as a regular property (Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#declarations-on-a-tpc-type-cover-every-table-of-the-hierarchy)).
- Constraints: a temporal foreign key to a TPC type with derived types fails at migrations add, since its rows live in several tables; reference a type with no derived types (Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#declarations-on-a-tpc-type-cover-every-table-of-the-hierarchy)).

Changed

- Indexes: a complex index, exclusion constraint or temporal constraint declared on the abstract base of a TPC hierarchy is created on every concrete table, as EF Core's own indexes are, where it failed at migrations add (Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#declarations-on-a-tpc-type-cover-every-table-of-the-hierarchy)).
- Indexes: an explicit name on a TPC type's complex index, exclusion constraint or temporal constraint stays on that type's table, and each derived table's copy takes the name followed by its table. A copy's name past the identifier limit is shortened as EF Core's are, a temporal foreign key's too (#145 (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/145), #167 (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/167), #168 (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/168), Upgrading (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/upgrading.md#declarations-on-a-tpc-type-cover-every-table-of-the-hierarchy)).
- Packaging: each package carries the version's section of its changelog as its NuGet release notes, which nuget.org's Release Notes tab and Visual Studio's Updates tab show.
- Packaging: the release notes, the README on nuget.org and the GitHub release link the documentation at the version's tag, so they keep describing the version they shipped with after main moves on to the next line.
- Packaging: the PostgreSQL and SQL Server packages depend on exactly their own version of EFCore.ComplexIndexes, and the core differ refuses a satellite of another version, naming both. Update the packages together: a 5.4.x satellite beside core 5.5.0 was refused as the core differ (#169 (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/169)).

Fixed

- Indexes: a complex index, exclusion constraint or temporal constraint named like a table, view or sequence of its schema, the sequence of an identity or serial column included, fails at migrations add, where it failed at apply with 42P07 (Index names (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/README.md#single-column-index-on-a-complex-property)).
- Expression indexes: a typed expression index or filter refuses a member of a scalar type, such as CreatedAt.Year, at the declaration, where it failed at migrations add. .Value and .HasValue on a nullable translate (Typed expression indexes (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/postgresql-indexes.md#typed-linq-expression-indexes)).
- Filters: a typed filter compares a char with a char literal, where it failed at apply on the char's code, and x.RevokedAt.HasValue renders IS NOT NULL, where it failed at migrations add (Typed filters (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/postgresql-indexes.md#typed-filters)).
- Filters: a filter of an index or exclusion constraint that compares a date or time ToJson() member with a column fails at migrations add, naming the member, where it failed at apply comparing text with a timestamp (JSON member indexes (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/postgresql-indexes.md#json-member-indexes)).
- Constraints: a migration that drops both tables of a temporal foreign key applies, Down() included, where it failed with 2BP01 when the principal's table sorted first (Temporal foreign keys (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/postgresql-constraints.md#temporal-foreign-keys-period--requires-postgresql-18)).
- Constraints: an exclusion constraint filter on a ToJson() number, boolean, uuid or enum member casts it as EF Core's queries do and applies, where it failed with 42883 or 42804 (Exclusion constraints (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/postgresql-constraints.md#exclusion-constraints-exclude)).
- Constraints: an exclusion constraint with a scalar element under gist gets btree_gist whatever the element's operator and however the method is spelled, so <> and UseMethod("GiST") apply, where they failed with 42704 when the extension was missing (Exclusion constraints (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/postgresql-constraints.md#exclusion-constraints-exclude)).
- Migrations: a complex index, exclusion or temporal constraint renamed away from a name the same migration gives to another object applies, where it failed with 42P07 because the renamed one still held the name. It is dropped before EF Core's operations and created after them, with any temporal foreign key referencing it (Index names (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/README.md#single-column-index-on-a-complex-property)).
- Docs: the temporal foreign key samples pass both type arguments to HasTemporalForeignKey, and compile (Temporal foreign keys (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/postgresql-constraints.md#temporal-foreign-keys-period--requires-postgresql-18)).
- Docs: the PostgreSQL index guide says to pass a fractional storage parameter as a string, since Npgsql's generator formats numbers with the current culture (Index methods (https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/docs/postgresql-indexes.md#index-methods-on-a-complex-property)).

Full changelog: https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/v5.5.0/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md