SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Develop AI solutions by using Azure data management services

AI-200 Azure Database for PostgreSQL: Entra Connections, Schema Design, Data Types and Connection Pooling

18 min readAI-200 · Develop AI solutions by using Azure data management servicesUpdated

Azure Database for PostgreSQL flexible server is Microsoft's managed PostgreSQL service, and in AI applications it holds conversations, documents, chunk metadata and usage records next to the embeddings. This lesson covers three AI-200 skills for it: connecting and querying from SDKs (psycopg 3 with Microsoft Entra tokens, bound parameters, jsonb adaptation and COPY), modelling schemas with the right data types, keys, relationships, partitions and indexes, and optimizing connections with application pools, the built-in PgBouncer and read replicas. Vector types, pgvector indexes and RAG retrieval are covered in the next lesson.

What you’ll learn
  • Connect to Azure Database for PostgreSQL with a Microsoft Entra token and a managed identity, create the identity's role with pgaadauth_create_principal, and keep pooled connections working after tokens expire.
  • Run parameterized queries in psycopg 3, send Python dicts to jsonb with Jsonb, and bulk-load rows with COPY.
  • Choose jsonb or json, timestamptz, numeric(p,s) and identity columns from the stated requirement.
  • Design keys, foreign keys with the right ON DELETE action, child-column indexes and range partitions with valid primary keys.
  • Pick composite, INCLUDE, expression, partial, GIN jsonb and pg_trgm indexes for a given query.
  • Size application pools across scaled-out instances, enable the built-in PgBouncer and fix transaction-pooling errors.
  • Offload lag-tolerant reads to a read replica while keeping read-your-own-writes on the primary.

Connecting with Microsoft Entra ID instead of a password

To connect an app to Azure Database for PostgreSQL flexible server without a stored password, sign in as a Microsoft Entra principal: request an access token for the scope https://ossrdbms-aad.database.windows.net/.default and pass that token as the password. The server must have Microsoft Entra authentication enabled, and the identity must exist as a database role.

from azure.identity import DefaultAzureCredential
import psycopg

SCOPE = "https://ossrdbms-aad.database.windows.net/.default"
cred = DefaultAzureCredential()

conn = psycopg.connect(
    host="myserver.postgres.database.azure.com",
    dbname="app",
    user="ingest-func",              # the identity's name
    password=cred.get_token(SCOPE).token,
    sslmode="require",
)

The scope is the same for every server: it names the Azure open-source databases token audience, not your server's host name. A token issued for any other audience, even another Azure service's valid token, is rejected by PostgreSQL.

Creating the database role for an identity

Azure RBAC roles such as Contributor manage the server resource; they do not let anyone sign in to the database. A Microsoft Entra administrator of the server creates the role inside PostgreSQL, connected to the postgres database:

SELECT * FROM pgaadauth_create_principal('ingest-func', false, false);
-- arguments: role name, is administrator, is MFA-only

For a managed identity, the role name is the identity's name (for a system-assigned identity, the name of the app or resource), and the app signs in with that same name as the user and a token as the password. The role has no PostgreSQL password of its own (the token is the credential), and the user name in the connection must match the role name exactly. After the role exists, grant it normal PostgreSQL privileges (GRANT on schemas and tables) in the application database.

Connections are encrypted: require_secure_transport is on by default, so a client that sets sslmode=disable is refused rather than made faster.

Token lifetime and connection pools

An Entra access token is checked only when a connection signs in. Tokens are short-lived (minutes to an hour), so an established connection keeps working after its token expires, but any new connection opened with an old token fails with a password authentication error.

That matters for pools. A pool opens new connections throughout its life: when it grows, when it replaces a broken connection and when a connection reaches max_lifetime (one hour by default in psycopg_pool). If the token is fetched once at startup and passed as a fixed value, the pool works at first and then fails a growing share of new connections hours later, while older connections still succeed.

The fix is to fetch the token per connection attempt. In psycopg_pool 3.3 or later, kwargs (and conninfo) can be a callable that the pool calls every time it connects; the shared credential caches tokens and refreshes them before they expire.

from psycopg_pool import ConnectionPool

def conn_kwargs():
    return {"password": cred.get_token(SCOPE).token}

pool = ConnectionPool(
    conninfo=f"host={HOST} dbname=app user={USER} sslmode=require",
    kwargs=conn_kwargs,
    max_size=10,
)

On older psycopg_pool versions kwargs must be a dict, so upgrade before using this pattern. The rule to remember: whatever opens the connection must fetch the token, and in a pool that is the pool itself. In .NET, Npgsql's data source builder offers the same pattern with a periodic password provider that supplies a fresh token.

Running queries safely with psycopg 3

Send user input to PostgreSQL as bound parameters, never as part of the SQL text. In psycopg 3 the placeholders are %s (positional, with a tuple or list) and %(name)s (named, with a dict), and the values travel separately from the statement.

with conn.cursor() as cur:
    cur.execute(
        "SELECT id, title FROM docs WHERE tenant_id = %s",
        (tenant_id,),          # note the one-element tuple
    )
    rows = cur.fetchall()

Any string formatting that puts the value into the SQL text before execute() runs reopens SQL injection, however tidy it looks; the value must reach the driver as a separate argument. Placeholder syntax differs between drivers, so use the style your driver documents.

Adapting Python dicts to jsonb

psycopg does not guess how to send a Python dict, so passing one directly raises an error that it cannot adapt the type. Wrap it in Jsonb (or Json for a json column) from psycopg.types.json:

from psycopg.types.json import Jsonb

cur.execute(
    "INSERT INTO usage_events (request_id, details) VALUES (%s, %s)",
    (request_id, Jsonb(details)),
)

Let the wrapper serialise the value: Python's own text form of a dict uses single quotes and is not valid JSON.

Bulk loading with COPY instead of row-by-row INSERTs

For millions of rows, use PostgreSQL's COPY protocol, which streams rows to the server in one command. In psycopg 3 that is cursor.copy() with write_row():

with conn.cursor() as cur:
    with cur.copy(
        "COPY staging_pages (source_id, content) FROM STDIN"
    ) as copy:
        for source_id, content in records:
            copy.write_row((source_id, content))
conn.commit()

COPY is fast because it removes per-row overhead. Each separate INSERT costs a network round trip and a statement execution, and each separate commit waits for a durable log write; adding concurrency spreads that cost around but does not remove it. COPY gives the highest throughput without another service; executemany() with batched parameters is the middle ground for moderate volumes.

Choosing data types for AI application tables

Pick the type that matches how the value is used: what you filter on, whether it must be exact, and whether it is a point in time. The table summarises the choices this topic tests.

NeedUseWhat it gives you
Flexible metadata (varying keys, nested objects, arrays) that queries filter onjsonbValidated, binary, normalised storage with GIN indexing and the containment and key-exists operators
A JSON payload kept exactly as received (whitespace, key order, duplicate keys), validated on insertjsonThe original text, checked to be valid JSON; jsonb would drop whitespace, reorder keys and keep only the last duplicate key
An instant in time from users or servers in different time zonestimestamptzValues that compare and sort correctly across zones, with full date/time functions
Exact amounts with chosen precision, such as costs to six decimalsnumeric(p,s)Exact decimal arithmetic at the scale you choose, so totals never drift
A compact surrogate key assigned only by the databasebigint GENERATED ALWAYS AS IDENTITYSee the next section

Properties of the other types that look similar: timestamp (without time zone) stores no zone information, and timetz has no date part. real and double precision are binary floating point and approximate. money stores a fixed number of fractional digits set by the lc_monetary locale (usually two). hstore is an extension that holds only flat text key/value pairs. Plain text and bytea accept anything, so they neither validate nor query JSON.

timestamptz does not store the original zone: it converts input to UTC and displays it in the session's TimeZone, which is exactly what makes ordering and AT TIME ZONE conversions correct across zones. The p in numeric(p,s) is total digits and s is digits after the decimal point, so numeric(14,6) holds up to eight digits before it.

Embedding columns use the vector type from pgvector; vector storage, indexes and search are covered in the lesson on pgvector indexing and vector search.

Primary keys and relationships between tables

Model each entity as a table with a primary key, and link child rows to parents with a foreign key whose ON DELETE action states what happens to children when a parent is deleted.

Generating keys

DefinitionValue comes fromApp-supplied value on INSERT
GENERATED ALWAYS AS IDENTITYAn owned sequenceRejected, unless the statement says OVERRIDING SYSTEM VALUE
GENERATED BY DEFAULT AS IDENTITYAn owned sequenceAccepted, and the sequence can later collide with it
serial / bigserialA sequence default (legacy shorthand)Accepted, same collision risk
uuid DEFAULT gen_random_uuid()A random 128-bit value (built in since PostgreSQL 13)Accepted; larger than an integer and random insert order

Identity columns are the SQL-standard replacement for serial. Choose ALWAYS when only the database may assign keys; UUIDs suit keys created by clients or merged across systems.

Foreign keys and delete behaviour

ActionDeleting a parent that still has children
NO ACTION (default) / RESTRICTFails with an error; children stay
CASCADEDeletes the children too
SET NULL / SET DEFAULTKeeps the children and detaches them

Add NOT NULL to the referencing column when every child must have a parent; a foreign key alone allows NULL. A CHECK constraint cannot express "this parent exists".

Index the referencing column yourself

PostgreSQL automatically indexes primary keys and unique constraints, so the parent side (projects.id) is already indexed. It does not index the referencing column (tasks.project_id). Without that index, every parent delete or update must scan the child table to find the rows to cascade or check, and joins from parent to children are slow. Create a B-tree index on each foreign key column you join or cascade on. Whatever enforces the relationship, finding the child rows is only fast with that index.

Partitioning large, time-based tables

Use declarative range partitioning on the time column when a table grows fast and old rows are removed by age. Each partition holds one range (a day or a month), and expiring data becomes a metadata operation: detach or drop the oldest partition.

CREATE SEQUENCE audit_events_id_seq;

CREATE TABLE audit_events (
    id          bigint NOT NULL
                DEFAULT nextval('audit_events_id_seq'),
    occurred_at timestamptz NOT NULL,
    detail      jsonb,
    PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);

CREATE TABLE audit_events_2026_11 PARTITION OF audit_events
    FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');

DROP TABLE audit_events_2026_08;   -- retention

The example uses a sequence default because identity columns on partitioned tables need PostgreSQL 17 or later; on 17+ you can write GENERATED ALWAYS AS IDENTITY instead.

Compared with a scheduled DELETE ... WHERE occurred_at < now() - interval '90 days', dropping a partition leaves no dead rows to vacuum (no bloat) and holds no long row-by-row locks. Queries that filter on occurred_at also benefit from partition pruning: the planner skips partitions outside the range. The partition scheme must match how data is removed: only a range on the time column lines whole partitions up with the retention window, while any scheme that spreads each period's rows across every partition leaves row-by-row deletes as the only way to expire them.

Keys on partitioned tables

A primary key or unique constraint on a partitioned table must include every partition key column, because each partition enforces uniqueness only within itself. ADD PRIMARY KEY (id) on a table partitioned by occurred_at fails; PRIMARY KEY (id, occurred_at) works and keeps range partitioning (and partition drops) intact. A constraint that exists only on individual partitions is not a key of the parent table.

Index strategies for relational filters

Design B-tree indexes around the exact WHERE and ORDER BY of your hot queries: equality columns first, then the range or sort column, so the planner can jump to the matching rows, read them already ordered and stop at the LIMIT.

-- WHERE workspace_id = $1 AND state = $2
-- ORDER BY updated_at DESC LIMIT 50
CREATE INDEX ON orders (workspace_id, state, updated_at DESC);

Order matters. Once the index reaches a range or sort column, the columns after it can no longer narrow the search, so a leading range column makes the scan read every entry in the range. And when the index order does not match ORDER BY, rows do not come out pre-sorted: the server must find and sort every match before it can apply LIMIT, which also happens when separate single-column indexes are combined with a bitmap scan.

Index formUse whenExample
CompositeSeveral columns are filtered/sorted together(workspace_id, state, updated_at DESC)
INCLUDE columnsExtra columns are only returned, so the query can be an index-only scan; included columns cannot be searched or used for ordering(workspace_id) INCLUDE (total)
ExpressionThe query filters on a function of a column(lower(email)) for WHERE lower(email) = lower($1)
PartialQueries touch a small, fixed subset of rows(run_at) WHERE state = 'queued'

An expression index is used only when the query uses the same expression; a plain index on email does not serve lower(email). A partial index is tiny when its WHERE clause matches few rows. Because that clause already selects the rows, the key should be the column the query sorts or ranges on; a key that repeats the filtered value adds nothing. Confirm index use with EXPLAIN (ANALYZE, BUFFERS).

Indexing jsonb and text search patterns

Index jsonb columns with GIN and choose the operator class by the operators your queries use.

GIN operator classSupportsSize
jsonb_ops (default)@> containment, ?, ?|, ?& key-exists, @?, @@ jsonpathLarger: indexes every key and value separately
jsonb_path_ops@>, @?, @@ only (no key-exists)Smaller and usually faster for containment
-- containment filters only (WHERE specs @> '{"color":"red"}')
CREATE INDEX ON catalog_items USING gin (specs jsonb_path_ops);

-- containment AND key-exists (WHERE attrs ? 'discontinued')
CREATE INDEX ON products USING gin (attrs);   -- jsonb_ops

Operator support is fixed by the operator class: if a query uses an operator the class lacks, the planner cannot use that index at all, whatever the statistics say. When one index must serve both containment and key-exists, use the default jsonb_ops. An expression B-tree on one extracted key, such as (specs ->> 'brand'), serves comparisons on that single key only.

Substring search with pg_trgm

For LIKE or ILIKE with a leading wildcard ('%term%'), use the pg_trgm extension with a GIN trigram index. Each text index serves a different kind of match:

IndexServes
B-tree (with text_pattern_ops under a non-C collation)Equality and anchored prefixes such as 'term%'
GIN on a tsvector (full-text search)Words and their stems, not arbitrary substrings
GIN or GiST with gin_trgm_ops/gist_trgm_ops (pg_trgm)Substrings anywhere, case-insensitive ILIKE, similarity
CREATE EXTENSION pg_trgm;
CREATE INDEX ON kb_articles USING gin (title gin_trgm_ops);

On Azure Database for PostgreSQL flexible server, an extension must first be allow-listed in the azure.extensions server parameter; otherwise CREATE EXTENSION fails. Only extensions that hook into server start-up also need shared_preload_libraries (and a restart); pg_trgm is not one of them.

Connection optimization: application pools and the built-in PgBouncer

Reuse connections instead of opening one per request. Every new PostgreSQL connection costs a TLS handshake, authentication and a new server process, which adds latency to the request and CPU on the server. Two layers solve this: an application-side pool in each process, and server-side pooling with the built-in PgBouncer.

Application pools

Create one pool per process at startup and borrow a connection per unit of work with the context manager:

pool = ConnectionPool(conninfo=..., min_size=1, max_size=5)

def handler(params):
    with pool.connection() as conn:
        return conn.execute(SQL, params).fetchall()

with pool.connection() always returns the connection, committing on success and rolling back on an exception. Code that borrows connections by hand must return them in a finally block. A connection that is never returned, for example because an exception skipped the return, stays checked out; once enough have leaked, requests wait for a free connection and time out even though the database is not busy. Raising pool limits hides a leak rather than fixing it, and the pool must be shared by the process: one created per request gives no reuse.

Sizing across scaled-out instances

The server sees instances × pool size connections, and that total must stay below max_connections minus the 15 connections Azure reserves for its own use. max_connections depends on the compute size, so check the limits page for your SKU. For example, if a server allowed 1,000 connections and an app scaled out to 50 instances, each with max_size=25, the pools could ask for 1,250; once the limit is reached, new connections fail with FATAL: sorry, too many clients already. Keep each instance's pool small (an instance that handles one request at a time needs very few connections) and put PgBouncer in front. Raising max_connections needs a server restart and costs memory per connection, and Microsoft recommends PgBouncer instead.

Built-in PgBouncer

Flexible server includes PgBouncer: set the server parameter pgbouncer.enabled to true (no restart needed) and point clients at port 6432 on the same host; port 5432 still goes directly to PostgreSQL. It is available on the General Purpose and Memory Optimized tiers only, not Burstable, so a Burstable server must be scaled to one of those tiers first. Its metrics are enabled separately with metrics.pgbouncer_diagnostics. Running your own PgBouncer in a sidecar or VM also works, but it is extra infrastructure to operate.

Transaction pooling rules: session state and prepared statements

The built-in PgBouncer defaults to transaction pooling: a client holds a server connection only for the duration of one transaction, and the next transaction may run on a different server connection. Anything tied to the session therefore leaks or disappears.

SymptomCauseFix
A session SET search_path (or other SET) is lost, or applies to another clientThe setting stays on the server connection, which other clients reuseSET LOCAL inside each transaction (or put the schema in the query)
prepared statement "_pg3_0" does not existThe driver prepared a statement on one server connection and runs it on anotherSet pgbouncer.max_prepared_statements above 0 so PgBouncer tracks protocol-level prepared statements (psycopg 3.2+, PgBouncer 1.22+), or disable driver-side preparing
Advisory locks, LISTEN, temporary tables or WITH HOLD cursors behave oddlySession features need one server connection for the sessionUse a direct connection on port 5432 for those workloads

Statement pooling is stricter still: it does not allow multi-statement transactions. No pool-size setting changes the core rule of transaction mode, which is that a client keeps a server connection for one transaction only.

Read replicas for lag-tolerant reads

A read replica is a separate, read-only flexible server that receives the primary's changes by asynchronous replication and has its own endpoint. Point reporting, analytics or other heavy read workloads that tolerate seconds of lag at the replica, and the primary keeps its capacity for writes and latency-sensitive reads.

FeatureReadable?Purpose
Read replicaYes, read-only, asynchronously updatedOffload reads, serve other regions, disaster recovery
High availability standby (zone-redundant or same-zone)NoAutomatic failover only
Built-in PgBouncerNot a copy of dataReuses connections; does not reduce query work

Because replication is asynchronous, a read sent to the replica right after a write may not see that write yet. Keep read-your-own-writes paths (for example, showing an order confirmation right after checkout) on the primary and send only lag-tolerant reads to the replica. Replication to a read replica is always asynchronous; there is no synchronous option. Split traffic by how fresh each read must be, not by user: only a separate server adds capacity, since a second database on the same server still shares its CPU and memory. Running the app in the same Azure region as the server, over private networking, also cuts round-trip latency for every query.

Tip. Expect scenario questions where a stated requirement (no stored password, smallest index, exact totals, no restart, no extra infrastructure, keep the pool) separates several workable PostgreSQL choices, and short Python or SQL snippets where you pick the missing argument, line or index definition. Items test the Entra token scope and role creation, token refresh in pools, psycopg parameters, Jsonb and COPY, data type and key choices, foreign key actions and child-column indexes, partitioning and its primary key rule, B-tree, GIN and trigram index forms, the built-in PgBouncer's port, tiers and transaction-mode limits, pool sizing across instances, and when to use a read replica. Vector columns and pgvector indexes belong to the next topic.

Key takeaways
  • Entra sign-in: token for https://ossrdbms-aad.database.windows.net/.default as the password, user name = the identity's name, role created with pgaadauth_create_principal in the postgres database.
  • Tokens are checked only at sign-in, so pools must fetch a fresh token for every new connection (callable kwargs in psycopg_pool 3.3+).
  • psycopg 3: %s or %(name)s placeholders with separate parameters, Jsonb(...) for dicts, cursor.copy() for bulk loads.
  • jsonb to filter and index flexible metadata; json only to keep the exact original text; timestamptz for instants; numeric(p,s) for exact amounts; GENERATED ALWAYS AS IDENTITY for database-only keys.
  • PostgreSQL does not index foreign key columns; index the child column you join or cascade on.
  • Retention by age: range-partition on the time column and drop old partitions; the primary key must include the partition key.
  • B-tree: equality columns first, then the range/sort column; expression indexes for functions, partial indexes for small fixed subsets.
  • GIN jsonb_path_ops for containment only (smaller); default jsonb_ops when key-exists operators are needed too; pg_trgm GIN for '%term%' after allow-listing it in azure.extensions.
  • One small pool per process plus built-in PgBouncer on port 6432 (General Purpose or Memory Optimized only); in transaction mode use SET LOCAL and max_prepared_statements.
  • Read replicas are asynchronous and readable; the HA standby is not. Route read-your-own-writes to the primary.

Frequently asked questions

What token scope does Azure Database for PostgreSQL use for Microsoft Entra authentication?

Applications request an access token for https://ossrdbms-aad.database.windows.net/.default and pass it as the password, with the Entra principal's role name as the user name. The scope is the same for every flexible server; it is not the server's host name and not the Azure SQL scope.

How do I let a managed identity sign in to Azure Database for PostgreSQL flexible server?

Enable Microsoft Entra authentication on the server, then, connected to the postgres database as a Microsoft Entra administrator, run SELECT * FROM pgaadauth_create_principal('<identity name>', false, false). The app then connects with the identity's name as the user and an Entra access token as the password, and you grant the role privileges with normal GRANT statements. Azure RBAC roles on the server resource do not grant database sign-in.

Why does my connection pool start failing with password authentication errors hours after startup when using Entra ID?

The token was fetched once and reused. Entra tokens expire within about an hour and are checked when a connection signs in, so connections the pool opens later with the stale token fail while older ones keep working. Pass the pool a callable (for example psycopg_pool's kwargs, callable from version 3.3) that gets a fresh token from a shared credential for every new connection.

When should I use jsonb instead of json in PostgreSQL?

Use jsonb for JSON you query: it is stored in a binary, normalised form that supports GIN indexes and operators such as containment (@>) and key-exists (?). Use json only when you must keep the exact original text, including whitespace, key order and duplicate keys, and rarely query it. Both validate that the input is JSON.

What is the difference between the jsonb_ops and jsonb_path_ops GIN operator classes?

jsonb_ops is the default and supports containment (@>), the key-exists operators ?, ?| and ?&, and jsonpath operators. jsonb_path_ops supports only containment and jsonpath operators, but it is smaller and usually faster for containment. Choose jsonb_path_ops when queries only use @>, and jsonb_ops when key-exists checks must also use the index.

How do I enable the built-in PgBouncer on Azure Database for PostgreSQL flexible server?

Set the server parameter pgbouncer.enabled to true, which needs no restart, and connect applications to port 6432 instead of 5432. The built-in PgBouncer is available on the General Purpose and Memory Optimized compute tiers, not Burstable, and it uses transaction pooling by default.

Why do I get 'prepared statement does not exist' errors behind PgBouncer?

In transaction pooling, consecutive transactions can run on different server connections, so a statement the driver prepared on one connection is missing on the next. Set pgbouncer.max_prepared_statements to a value above 0 so PgBouncer tracks protocol-level prepared statements, or turn off driver-side preparing. Session-level SET commands have the same problem; use SET LOCAL inside each transaction instead.

Can I read from the high-availability standby of a PostgreSQL flexible server?

No. The high-availability standby exists only for automatic failover and does not accept connections. To offload reads, create a read replica, which is a separate read-only server updated by asynchronous replication with its own endpoint. Because it can lag, keep reads that must see the user's own latest writes on the primary.

Source

This lesson covers the "Develop AI solutions by using Azure data management services" domain of the official AI-200 exam guide. Vendors revise their guides — check the source for the current version.

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?