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:
-- ✅ CORRECT: Snake case
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')
);
-- ❌ WRONG: PascalCase or camelCase
CREATE TABLE UserProfiles (
UserId uuid PRIMARY KEY,
firstName text NOT NULL
);
EF Core Snake Case Setup
// DependencyInjection.cs
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(connectionString)
.UseSnakeCaseNamingConvention(); // Converts C# PascalCase to snake_case
});
# Install package
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
-- ✅ PREFERRED: Use text for most string columns
CREATE TABLE products (
id uuid PRIMARY KEY,
name text NOT NULL,
description text
);
-- ⚠️ USE SPARINGLY: Only when you need to enforce length
CREATE TABLE users (
id uuid PRIMARY KEY,
email varchar(255) NOT NULL -- Email has practical length limit
);
Why text?
- No performance difference in PostgreSQL
- More flexible (no arbitrary limits)
- Easier to modify (no migration needed to change length)
Timestamps
-- ✅ CORRECT: timestamptz with UTC default
created_at timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
updated_at timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
-- ❌ WRONG: timestamp without time zone
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
// EF Core configuration
builder.Property(e => e.CreatedAt)
.HasColumnType("timestamptz")
.IsRequired()
.HasDefaultValueSql("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
JSONB for Flexible Data
-- ✅ Use jsonb (binary JSON, faster, indexable)
metadata jsonb
-- ❌ Don't use json (text-based, slower)
metadata json
// EF Core configuration
builder.Property(e => e.Metadata)
.HasColumnType("jsonb");
// Or for owned types (EF Core 7+)
builder.OwnsOne(e => e.Settings, settingsBuilder =>
{
settingsBuilder.ToJson(); // Stores as jsonb
});
Primary Keys
UUID (Guid) - Recommended
-- ✅ RECOMMENDED: UUID primary keys
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL
);
// EF Core - App generates GUIDs
builder.Property(e => e.Id)
.ValueGeneratedNever(); // Don't let DB generate
// In domain entity
public static User Create(...)
{
return new User(Guid.NewGuid(), ...); // App generates
}
Benefits:
- Globally unique (no collisions across databases)
- Can generate client-side
- Easier for distributed systems
- No sequential enumeration security risk
Serial/BigSerial Alternative
-- Alternative: Auto-increment
CREATE TABLE orders (
id bigserial PRIMARY KEY,
order_number text NOT NULL
);
// EF Core
builder.Property(e => e.Id)
.UseIdentityColumn(); // PostgreSQL IDENTITY
Indexing Strategies
Index Foreign Keys
-- ✅ ALWAYS 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
-- ✅ Unique constraints
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
-- ✅ Composite indexes for common query patterns
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
-- ✅ Index only relevant rows
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;
// EF Core
builder.HasIndex(o => o.CreatedAt)
.HasFilter("status = 'Pending'");
Covering Indexes (INCLUDE)
-- ✅ Include frequently accessed columns
CREATE INDEX ix_users_email_include ON users(email)
INCLUDE (first_name, last_name);
Full-Text Search Indexes
-- ✅ GIN index for full-text search
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
-- ✅ GIN index for JSONB queries
CREATE INDEX ix_products_metadata ON products USING GIN(metadata);
-- Specific path index
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
-- ✅ Named primary key
CONSTRAINT pk_users PRIMARY KEY (id)
// EF Core names automatically: pk_{table}
builder.HasKey(u => u.Id);
Foreign Key Constraints
-- ✅ Named foreign keys with appropriate delete behavior
CONSTRAINT fk_orders_users
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE RESTRICT; -- Prevent orphans
CONSTRAINT fk_order_items_orders
FOREIGN KEY (order_id)
REFERENCES orders(id)
ON DELETE CASCADE; -- Delete items with order
builder.HasOne(o => o.User)
.WithMany(u => u.Orders)
.HasForeignKey(o => o.UserId)
.OnDelete(DeleteBehavior.Restrict);