| name | dotnet-postgresql-best-practices |
| description | PostgreSQL database design best practices, naming conventions, indexing strategies, and performance optimization for .NET applications using Npgsql and EF Core. |
| version | 1.0.0 |
| language | SQL/C# |
| framework | PostgreSQL 14+, .NET 8+ |
| dependencies | Npgsql.EntityFrameworkCore.PostgreSQL, EFCore.NamingConventions |
| inspiration | johnpuksta/clean-architecture-agents (https://github.com/johnpuksta/clean-architecture-agents) |
PostgreSQL Best Practices for .NET
Overview
Best practices for PostgreSQL database design, naming conventions, indexing, and performance optimization when using with .NET and Entity Framework Core.
Quick Reference
| Category | Best Practice |
|---|
| Naming | snake_case for tables/columns |
| Primary Keys | Use uuid (Guid) or bigserial |
| Timestamps | Use timestamptz with UTC |
| Indexes | Index foreign keys, unique constraints |
| Text | Use text not varchar unless limit needed |
| JSON | Use jsonb not json |
Naming Conventions
Snake Case Standard
PostgreSQL convention is snake_case for all identifiers:
CREATE TABLE user_profiles (
user_id uuid PRIMARY KEY,
first_name text NOT NULL,
last_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
updated_at timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
);
CREATE TABLE UserProfiles (
UserId uuid PRIMARY KEY,
firstName text NOT NULL
);
EF Core Snake Case Setup
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(connectionString)
.UseSnakeCaseNamingConvention();
});
dotnet add package EFCore.NamingConventions
Naming Patterns
| Object | Pattern | Example |
|---|
| Tables | snake_case (plural) | user_profiles, order_items |
| Columns | snake_case | first_name, created_at |
| Primary Keys | pk_{table} | pk_users, pk_orders |
| Foreign Keys | fk_{table}_{ref_table} | fk_orders_users |
| Indexes | ix_{table}_{column(s)} | ix_users_email |
| Unique Indexes | uix_{table}_{column(s)} | uix_users_email |
| Check Constraints | ck_{table}_{column} | ck_users_age |
| Sequences | seq_{table}_{column} | seq_orders_order_number |
Data Types
Recommended Types
| C# Type | PostgreSQL Type | Notes |
|---|
Guid | uuid | Preferred for primary keys |
string (unlimited) | text | More flexible than varchar |
string (limited) | varchar(n) | Only when length limit needed |
int | integer | 4 bytes, -2B to +2B |
long | bigint | 8 bytes |
decimal | numeric(p,s) | Exact precision |
double | double precision | Floating point |
bool | boolean | true/false/null |
DateTime | timestamptz | Always use with time zone |
byte[] | bytea | Binary data |
Dictionary<string,object> | jsonb | Structured data |
string[] | text[] | Array type |
Text vs Varchar
CREATE TABLE products (
id uuid PRIMARY KEY,
name text NOT NULL,
description text
);
CREATE TABLE users (
id uuid PRIMARY KEY,
email varchar(255) NOT NULL
);
Why text?
- No performance difference in PostgreSQL
- More flexible (no arbitrary limits)
- Easier to modify (no migration needed to change length)
Timestamps
created_at timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
updated_at timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
builder.Property(e => e.CreatedAt)
.HasColumnType("timestamptz")
.IsRequired()
.HasDefaultValueSql("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
JSONB for Flexible Data
metadata jsonb
metadata json
builder.Property(e => e.Metadata)
.HasColumnType("jsonb");
builder.OwnsOne(e => e.Settings, settingsBuilder =>
{
settingsBuilder.ToJson();
});
Primary Keys
UUID (Guid) - Recommended
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL
);
builder.Property(e => e.Id)
.ValueGeneratedNever();
public static User Create(...)
{
return new User(Guid.NewGuid(), ...);
}
Benefits:
- Globally unique (no collisions across databases)
- Can generate client-side
- Easier for distributed systems
- No sequential enumeration security risk
Serial/BigSerial Alternative
CREATE TABLE orders (
id bigserial PRIMARY KEY,
order_number text NOT NULL
);
builder.Property(e => e.Id)
.UseIdentityColumn();
Indexing Strategies
Index Foreign Keys
CREATE INDEX ix_orders_user_id ON orders(user_id);
CREATE INDEX ix_order_items_order_id ON order_items(order_id);
EF Core creates these automatically, but verify:
builder.HasIndex(o => o.UserId);
Unique Indexes
CREATE UNIQUE INDEX uix_users_email ON users(email);
CREATE UNIQUE INDEX uix_users_username ON users(username);
builder.HasIndex(u => u.Email).IsUnique();
Composite Indexes
CREATE INDEX ix_orders_user_status ON orders(user_id, status);
CREATE INDEX ix_orders_created_status ON orders(created_at DESC, status);
Order matters! Index on (user_id, status) helps:
WHERE user_id = ? AND status = ? ✅
WHERE user_id = ? ✅
WHERE status = ? ❌ (doesn't use index)
Partial Indexes
CREATE INDEX ix_orders_pending ON orders(created_at)
WHERE status = 'Pending';
CREATE INDEX ix_users_active_email ON users(email)
WHERE is_deleted = false;
builder.HasIndex(o => o.CreatedAt)
.HasFilter("status = 'Pending'");
Covering Indexes (INCLUDE)
CREATE INDEX ix_users_email_include ON users(email)
INCLUDE (first_name, last_name);
Full-Text Search Indexes
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, ''))
) STORED;
CREATE INDEX ix_products_search ON products USING GIN(search_vector);
JSONB Indexes
CREATE INDEX ix_products_metadata ON products USING GIN(metadata);
CREATE INDEX ix_products_metadata_tags ON products USING GIN((metadata -> 'tags'));
When NOT to Index
❌ Don't index:
- Very small tables (< 1000 rows)
- Columns rarely used in WHERE/JOIN
- Columns with low cardinality (few distinct values)
- Columns that change frequently
Constraints
Primary Key Constraints
CONSTRAINT pk_users PRIMARY KEY (id)
builder.HasKey(u => u.Id);
Foreign Key Constraints
CONSTRAINT fk_orders_users
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE RESTRICT;
CONSTRAINT fk_order_items_orders
FOREIGN KEY (order_id)
REFERENCES orders(id)
ON DELETE CASCADE;
builder.HasOne(o => o.User)
.WithMany(u => u.Orders)
.HasForeignKey(o => o.UserId)
.OnDelete(DeleteBehavior.Restrict);
Check Constraints
CONSTRAINT ck_users_age CHECK (age >= 18 AND age <= 120)
CONSTRAINT ck_products_price CHECK (price >= 0)
CONSTRAINT ck_orders_quantity CHECK (quantity > 0)
builder.ToTable(t => t.HasCheckConstraint(
"ck_products_price",
"price >= 0"));
Unique Constraints
CONSTRAINT uq_user_profiles_user_type UNIQUE (user_id, profile_type)
builder.HasIndex(p => new { p.UserId, p.ProfileType }).IsUnique();
Performance Optimization
Connection Pooling
"Host=localhost;Database=mydb;Username=postgres;Password=pass;Pooling=true;MinPoolSize=1;MaxPoolSize=100"
Prepared Statements
Npgsql automatically uses prepared statements for repeated queries.
var users = await context.Users
.Where(u => u.Email == email)
.ToListAsync();
Batch Operations
context.Users.AddRange(users);
await context.SaveChangesAsync();
foreach (var user in users)
{
context.Users.Add(user);
await context.SaveChangesAsync();
}
AsNoTracking for Read-Only Queries
var users = await context.Users
.AsNoTracking()
.ToListAsync();
var user = await context.Users.FindAsync(id);
user.Update(...);
await context.SaveChangesAsync();
Compiled Queries
private static readonly Func<ApplicationDbContext, string, Task<User?>> GetUserByEmail =
EF.CompileAsyncQuery((ApplicationDbContext context, string email) =>
context.Users.FirstOrDefault(u => u.Email == email));
var user = await GetUserByEmail(context, email);
Pagination
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
var users = await context.Users
.OrderByDescending(u => u.CreatedAt)
.Skip(pageNumber * pageSize)
.Take(pageSize)
.ToListAsync();
Avoid N+1 Queries
var orders = await context.Orders.ToListAsync();
foreach (var order in orders)
{
var user = await context.Users.FindAsync(order.UserId);
}
var orders = await context.Orders
.Include(o => o.User)
.ToListAsync();
var orders = await context.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSplitQuery()
.ToListAsync();
Maintenance
Analyze and Vacuum
ANALYZE users;
ANALYZE VERBOSE;
VACUUM users;
VACUUM ANALYZE users;
Autovacuum Configuration
PostgreSQL runs autovacuum automatically, but tune if needed:
SHOW autovacuum;
SHOW autovacuum_naptime;
ALTER TABLE high_traffic_table SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02
);
Monitor Index Usage
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
SELECT
schemaname,
tablename,
seq_scan,
seq_tup_read,
idx_scan,
seq_tup_read / seq_scan AS avg_seq_tup
FROM pg_stat_user_tables
WHERE seq_scan > 0
ORDER BY seq_tup_read DESC
LIMIT 20;
Table Bloat
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename) - pg_relation_size(schemaname||'.'||tablename)) AS external_size
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
Security Best Practices
Connection Security
"Host=prod-server;Database=mydb;Username=app_user;Password=pass;SSL Mode=Require"
"Host=localhost;Database=mydb;Username=postgres;Password=pass;MaxPoolSize=50;Timeout=30"
Least Privilege
CREATE USER app_user WITH PASSWORD 'secure_password';
GRANT CONNECT ON DATABASE mydb TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
Parameterized Queries
var users = await context.Users
.Where(u => u.Email == email)
.ToListAsync();
var users = await connection.QueryAsync<User>(
"SELECT * FROM users WHERE email = @Email",
new { Email = email });
var sql = $"SELECT * FROM users WHERE email = '{email}'";
PostgreSQL Extensions
Recommended Extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
CREATE EXTENSION IF NOT EXISTS "citext";
CREATE EXTENSION IF NOT EXISTS "hstore";
CREATE EXTENSION IF NOT EXISTS "ltree";
Using Extensions
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
);
CREATE TABLE users (
id uuid PRIMARY KEY,
email citext UNIQUE NOT NULL
);
CREATE INDEX ix_products_name_trgm ON products USING GIN(name gin_trgm_ops);
SELECT * FROM products
WHERE name % 'search term'
ORDER BY similarity(name, 'search term') DESC;
High Concurrency Patterns
Optimistic Concurrency with xmin System Column
PostgreSQL has hidden system columns including xmin which holds the transaction ID of the last update. This is ideal for optimistic concurrency:
public class Order
{
public Guid Id { get; private set; }
public string Status { get; private set; }
public uint RowVersion { get; private set; }
}
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.RowVersion)
.IsRowVersion();
}
}
Handling Concurrency Conflicts
public async Task UpdateAsync(Order order, CancellationToken cancellationToken)
{
try
{
context.Orders.Update(order);
await context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync(cancellationToken);
if (databaseValues is null)
{
throw new EntityNotFoundException("Order was deleted by another user");
}
throw new ConcurrencyException("Order was modified by another user. Please refresh and try again.");
}
}
Retry with Exponential Backoff (Polly)
using Polly;
using Polly.Retry;
services.AddResiliencePipeline("database", builder =>
{
builder.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder()
.Handle<DbUpdateConcurrencyException>()
.Handle<NpgsqlException>(ex => ex.IsTransient),
MaxRetryAttempts = 3,
Delay = TimeSpan.FromMilliseconds(200),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
OnRetry = static args =>
{
Console.WriteLine($"Retry attempt {args.AttemptNumber} after {args.RetryDelay}");
return default;
}
});
});
public class UpdateOrderHandler : ICommandHandler<UpdateOrderCommand, Unit>
{
private readonly ResiliencePipeline _pipeline;
public async Task<Unit> HandleAsync(UpdateOrderCommand command, CancellationToken ct)
{
await _pipeline.ExecuteAsync(async token =>
{
var order = await _repository.GetByIdAsync(command.OrderId, token);
order.Update(command.Status);
await _repository.UpdateAsync(order, token);
}, ct);
return Unit.Value;
}
}
DbContext Pooling for High Performance
services.AddDbContextPool<ApplicationDbContext>(options =>
{
options.UseNpgsql(connectionString)
.UseSnakeCaseNamingConvention();
}, poolSize: 128);
services.AddPooledDbContextFactory<ApplicationDbContext>(options =>
{
options.UseNpgsql(connectionString)
.UseSnakeCaseNamingConvention();
});
public class HighPerformanceQueryHandler
{
private readonly IDbContextFactory<ApplicationDbContext> _contextFactory;
public async Task<List<Order>> GetOrdersAsync(CancellationToken ct)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
return await context.Orders
.AsNoTracking()
.Where(o => o.Status == "Active")
.ToListAsync(ct);
}
}
Connection Pooling Configuration
var connectionString = new NpgsqlConnectionStringBuilder
{
Host = "localhost",
Database = "mydb",
Username = "app_user",
Password = "secret",
Pooling = true,
MinPoolSize = 10,
MaxPoolSize = 100,
ConnectionIdleLifetime = 300,
ConnectionPruningInterval = 10,
Timeout = 30,
CommandTimeout = 60,
MaxAutoPrepare = 20,
AutoPrepareMinUsages = 5,
WriteBufferSize = 16384,
ReadBufferSize = 16384,
Multiplexing = true,
SslMode = SslMode.Require,
TrustServerCertificate = false
}.ConnectionString;
Row-Level Locking for Critical Operations
SELECT * FROM accounts WHERE id = $1 FOR UPDATE;
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
SELECT * FROM accounts WHERE id = $1 FOR UPDATE NOWAIT;
public async Task<Account?> GetForUpdateAsync(Guid id, CancellationToken ct)
{
return await context.Accounts
.FromSqlInterpolated($"SELECT * FROM accounts WHERE id = {id} FOR UPDATE")
.FirstOrDefaultAsync(ct);
}
public async Task<List<Job>> GetPendingJobsAsync(int batchSize, CancellationToken ct)
{
return await context.Jobs
.FromSqlInterpolated($@"
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT {batchSize}
FOR UPDATE SKIP LOCKED")
.ToListAsync(ct);
}
Advisory Locks for Distributed Coordination
SELECT pg_advisory_lock(12345);
SELECT pg_try_advisory_lock(12345);
SELECT pg_advisory_unlock(12345);
public class AdvisoryLockService
{
private readonly ApplicationDbContext _context;
public async Task<bool> TryAcquireLockAsync(long lockId, CancellationToken ct)
{
var result = await _context.Database
.SqlQuery<bool>($"SELECT pg_try_advisory_lock({lockId})")
.FirstAsync(ct);
return result;
}
public async Task ReleaseLockAsync(long lockId, CancellationToken ct)
{
await _context.Database
.ExecuteSqlAsync($"SELECT pg_advisory_unlock({lockId})", ct);
}
}
public async Task ProcessDailyReportAsync(CancellationToken ct)
{
const long DAILY_REPORT_LOCK = 1001;
if (!await _lockService.TryAcquireLockAsync(DAILY_REPORT_LOCK, ct))
{
_logger.LogInformation("Another instance is processing the daily report");
return;
}
try
{
await GenerateReportAsync(ct);
}
finally
{
await _lockService.ReleaseLockAsync(DAILY_REPORT_LOCK, ct);
}
}
Disable Thread Safety Checks (High Performance)
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(connectionString)
.UseSnakeCaseNamingConvention()
.EnableThreadSafetyChecks(false);
});
Critical Rules
- Use snake_case - PostgreSQL standard, use EFCore.NamingConventions
- Use text over varchar - Unless you need length enforcement
- Always use timestamptz - Store times in UTC
- Index foreign keys - EF Core does this automatically
- Use uuid for PKs - Better for distributed systems
- Use jsonb not json - Binary format is faster
- Parameterize queries - Prevent SQL injection
- Monitor index usage - Drop unused indexes
- Use connection pooling - Enabled by default
- Run ANALYZE regularly - Keep statistics current
- Use xmin for optimistic concurrency - Auto-updates on every row change
- Implement retry with backoff - Handle transient failures gracefully
Related Skills
06-dotnet-ef-core-configuration - EF Core entity configurations
05-dotnet-repository-pattern - Data access patterns
19-dotnet-dapper-query-builder - Raw SQL with Dapper
01-dotnet-clean-architecture - Overall architecture