Migrar de PostgreSQL a Microsoft SQL con mssql-python

Muchos equipos de Python aprenden primero PostgreSQL. Cuando tu carga de trabajo necesite funciones como tablas temporales, semántica completa MERGE o índices de columnstore, migra a Microsoft SQL. Esta guía cubre las principales decisiones y cambios de código para mover una aplicación Python de PostgreSQL (usando psycopg2 o psycopg3) a Microsoft SQL mediante el mssql-python controlador.

Note

Si estás migrando desde Azure Database for PostgreSQL, ambos servicios soportan autenticación Microsoft Entra e identidad gestionada. Los cambios de código de esta guía se aplican independientemente de si tu código fuente de PostgreSQL es autogestionado o alojado en Azure.

Qué ganas al pasar a Microsoft SQL

Microsoft SQL incluye capacidades que simplifican la seguridad, el cumplimiento y las operaciones para las cargas de trabajo en producción. Entiende estas características antes de empezar a migrar para poder aprovecharlas durante la transición:

  • Enmascaramiento dinámico de datos y seguridad a nivel de fila. Oculta columnas para los usuarios que no necesitan acceso completo y restringe la visibilidad de las filas en función de la política de seguridad. Estas funciones funcionan con cualquier conductor.
  • Tablas temporales (versión del sistema). Microsoft SQL rastrea automáticamente el historial de filas. Sin disparadores, sin tablas de auditoría, sin código de aplicación.
  • Semántica MERGE completa. Una sola instrucción gestiona INSERT, UPDATE y DELETE con una cláusula OUTPUT para los registros de auditoría. La cláusula ON CONFLICT de PostgreSQL solo contempla la inserción o actualización para una única restricción.
  • Índices de almacén de columnas. Añadir almacenamiento columnar a tablas existentes para cargas de trabajo híbridas OLTP/analítica. No hace falta una base de datos analítica separada.
  • Autenticación de Microsoft Entra ID. Conéctate con identidades gestionadas, responsables de servicio o inicio de sesión interactivo. Azure Database for PostgreSQL también soporta autenticación Microsoft Entra, así que si ya lo usas, la transición es sencilla.

Instalación del controlador

Antes de empezar, asegúrate de tener Python 3.10 o posterior y una base de datos SQL de destino.

Creación de una base de datos SQL

Crea o conéctate a una base de datos SQL en una de las siguientes plataformas:

Los controladores PostgreSQL requieren bibliotecas nativas externas.

# psycopg2 requires pg_config, libpq-dev, and platform-specific build tools
sudo apt-get install libpq-dev  # Debian/Ubuntu
pip install psycopg2

El mssql-python driver incluye su capa nativa. En Windows, no necesitas un gestor de controladores externo ni paquetes de sistema.

pip install mssql-python

En Linux y macOS, instala un pequeño conjunto de bibliotecas del sistema documentadas en Instalación. No existe un equivalente a pg_config ni libpq-dev.

Actualizar código de conexión

Las siguientes secciones cubren los cambios clave en cadenas de conexión, autenticación, gestores de contexto y pooling.

Cadenas de conexión

psycopg2 utiliza una cadena DSN o argumentos de palabras clave.

import psycopg2

conn = psycopg2.connect(
    host="<server>",
    dbname="<database>",
    user="<username>",
    password="<password>"
)

MSSQL-Python también soporta argumentos de palabras clave, lo que evita los problemas de codificación de URLs que suelen tener las cadenas de conexión SQLAlchemy cuando las contraseñas contienen @, ;, o {} caracteres.

import mssql_python

conn = mssql_python.connect(
    server="<server>.database.windows.net",
    database="<database>",
    authentication="ActiveDirectoryDefault",
    encrypt="yes"
)

O usa una cadena de conexión.

conn = mssql_python.connect(
    "Server=<server>.database.windows.net;"
    "Database=<database>;"
    "Authentication=ActiveDirectoryDefault;"
    "Encrypt=yes;"
)

Para el conjunto completo de palabras clave de cadena de conexión, véase Connection strings.

Autenticación

La autenticación PostgreSQL suele usar pg_hba.conf reglas con nombre de usuario y contraseña. Azure Database for PostgreSQL también soporta autenticación Microsoft Entra. Microsoft SQL soporta múltiples modos de autenticación mediante una única palabra clave de conexión:

Enfoque PostgreSQL Equivalente a mssql-python
Nombre de usuario y contraseña UID=...;PWD=...;
Cifrado SSL/TLS Encrypt=yes;(activado por defecto para Azure SQL)
Entra auth (Azure PostgreSQL) Authentication=ActiveDirectoryDefault; (sin contraseña)
Managed identity (Azure PostgreSQL) Authentication=ActiveDirectoryMSI;
Service principal (Azure PostgreSQL) Authentication=ActiveDirectoryServicePrincipal;

Usa ActiveDirectoryDefault para el desarrollo local. Recorre automáticamente CLI de Azure, las variables de entorno y la identidad administrada. Para producción, usa un modo específico como ActiveDirectoryMSI (identidad gestionada) o ActiveDirectoryServicePrincipal para evitar la lentitud de la cadena de credenciales. Consulta la autenticación de Microsoft Entra para conocer los siete modos de autenticación.

Administradores de contexto

Ambos controladores soportan gestores de contexto, pero el comportamiento es diferente:

with conn: de psycopg2 confirma los cambios si se realiza correctamente y los revierte si se produce una excepción, pero no cierra la conexión:

with psycopg2.connect(...) as conn:
    with conn.cursor() as cur:
        cur.execute("INSERT INTO ...")
    # conn.commit() happens automatically on success
# Connection is still open here
conn.close()  # Must close explicitly

MSSQL-Python with conn: cierra la conexión al salir. El trabajo no comprometido se revierte:

with mssql_python.connect(...) as conn:
    with conn.cursor() as cursor:
        cursor.execute("INSERT INTO ...")
    conn.commit()
# Connection is closed here

Agrupación de conexiones

PsyCopG2 requiere una configuración y gestión explícita de un pool de conexiones.

from psycopg2 import pool

connection_pool = pool.ThreadedConnectionPool(1, 10, dsn="...")
conn = connection_pool.getconn()
# ... use conn ...
connection_pool.putconn(conn)

El mssql-python controlador tiene el pooling integrado activado por defecto. No hace falta ninguna configuración.

# Pooling is automatic. Each connect() call reuses pooled connections.
conn = mssql_python.connect(...)

Configura el tamaño del pool si los valores predeterminados no encajan con tu carga de trabajo.

import mssql_python

mssql_python.pooling(max_size=20, idle_timeout=300)

Para obtener orientación sobre el tamaño del pool y cómo solucionar problemas de agotamiento del pool, véase la agrupación de conexiones.

Diferencias entre dialectos de SQL

La siguiente tabla mapea patrones comunes de PostgreSQL a sus equivalentes Transact-SQL (T-SQL):

PostgreSQL SQL Server (T-SQL) Notas
SERIAL / BIGSERIAL int IDENTITY(1,1) Microsoft SQL usa IDENTITY para autoincremento.
TEXT nvarchar(max) Usa nvarchar para Unicode. Prefiere nvarchar(4000) o es más corto cuando los datos lo permitan.
BOOLEAN bit PostgreSQL acepta true/false; Microsoft SQL utiliza 1/0.
BYTEA varbinary(max) Mismo concepto, nombre diferente.
JSONB nvarchar(max) con funciones JSON Microsoft SQL almacena JSON como texto y valida con ISJSON(). Consulta datos JSON.
TIMESTAMP WITH TIME ZONE datetimeoffset Ambos almacenan offset. Consulta Manejo de la hora de cita.
INTERVAL Sin equivalente directo Calcula con DATEADD() y DATEDIFF().
ARRAY Sin equivalente directo Usa una tabla aparte, un array JSON o STRING_SPLIT().
UUID uniqueidentifier El controlador mssql-python asigna uuid.UUID de forma nativa. Ver configuración del módulo.
NOW() / CURRENT_TIMESTAMP GETDATE() o SYSDATETIME() SYSDATETIME() Proporciona mayor precisión.
LIMIT 10 OFFSET 20 OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY Requiere una ORDER BY cláusula.
\|\| (concat de cadena) + o CONCAT() CONCAT() gestiona NULL valores.
COALESCE(a, b) COALESCE(a, b) o ISNULL(a, b) COALESCE es idéntico en ambos.
string_agg(col, ',') STRING_AGG(col, ',') Disponible en SQL Server 2017+.
RETURNING id OUTPUT INSERTED.id Use OUTPUT en la instrucción INSERT, UPDATE o DELETE.
ON CONFLICT ... DO UPDATE Instrucción MERGE MERGE soporta INSERT + UPDATEDELETE + en una sentencia. Consulta Patrones de reescritura de consultas.
EXPLAIN ANALYZE SET STATISTICS IO ON; SET STATISTICS TIME ON; O usar planes de ejecución en SSMS / Azure Data Studio.
\d tablename sp_help 'tablename' O consultar INFORMATION_SCHEMA.COLUMNS.
pg_dump bcp, BACKUP DATABASE Úsalo bulkcopy() para cargar datos programáticos desde Python.

CREATE TABLE ejemplo

PostgreSQL:

CREATE TABLE IF NOT EXISTS products (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    price NUMERIC(10, 2) DEFAULT 0.0,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    metadata JSONB,
    is_active BOOLEAN DEFAULT TRUE
);

SQL Server:

IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'products')
CREATE TABLE products (
    id int IDENTITY(1,1) PRIMARY KEY,
    name nvarchar(100) NOT NULL,
    price decimal(10,2) DEFAULT 0.0,
    created_at datetimeoffset DEFAULT SYSDATETIMEOFFSET(),
    metadata nvarchar(max),
    is_active bit DEFAULT 1
);

Patrones de reescritura de consultas

Las siguientes secciones muestran patrones comunes de consulta PostgreSQL y sus equivalentes en T-SQL.

Pagination

PostgreSQL:

cursor.execute("SELECT * FROM products ORDER BY name LIMIT %s OFFSET %s", (10, 20))

MSSQL-Python:

cursor.execute(
    "SELECT * FROM Production.Product ORDER BY Name OFFSET ? ROWS FETCH NEXT ? ROWS ONLY",
    (20, 10)
)

El orden de los parámetros se invierte. Microsoft SQL pone OFFSET antes FETCH NEXTde .

Upsert (insertar o actualizar)

PostgreSQL ON CONFLICT gestiona la inserción o actualización con una única restricción:

cursor.execute("""
    INSERT INTO settings (key, value)
    VALUES (%s, %s)
    ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, value))

Microsoft SQL MERGE gestiona INSERT, UPDATE, y DELETE en una sola sentencia. Utiliza una USING cláusula con alias de parámetros:

cursor.execute("""
    MERGE #Settings AS target
    USING (SELECT ? AS [key], ? AS value) AS source
    ON target.[key] = source.[key]
    WHEN MATCHED THEN UPDATE SET value = source.value
    WHEN NOT MATCHED THEN INSERT ([key], value) VALUES (source.[key], source.value);
""", (key, value))

Para operaciones de inserción o actualización en lote, carga las filas en una tabla temporal usando bulkcopy() y, a continuación, MERGE desde esa tabla. Para más información, véase Bulk upsert con una tabla de preparación.

Inserta el DNI

PostgreSQL:

cursor.execute(
    "INSERT INTO products (name) VALUES (%s) RETURNING id",
    ("Widget",)
)
product_id = cursor.fetchone()[0]

MSSQL-Python:

cursor.execute(
    "INSERT INTO #Products (Name) OUTPUT INSERTED.ProductID VALUES (%(name)s)",
    {"name": "Widget"}
)
product_id = cursor.fetchval()

OUTPUT INSERTED funciona con las sentencias INSERT, UPDATE y DELETE. Puede devolver varias columnas.

Marcadores de parámetros

Psycopg2 utiliza %s para parámetros posicionales y %(name)s para parámetros nombrados. El controlador mssql-python utiliza ? para parámetros posicionales y %(name)s para parámetros con nombre:

Psycopg2:

cursor.execute("SELECT * FROM products WHERE id = %s", (42,))
cursor.execute("SELECT * FROM products WHERE id = %(id)s", {"id": 42})

MSSQL-Python:

cursor.execute("SELECT * FROM Production.Product WHERE ProductID = ?", (42,))
cursor.execute(
    "SELECT * FROM Production.Product WHERE ProductID = %(id)s", {"id": 42}
)

Diferencias entre transacciones y autocommit

PostgreSQL (psycopg2) abre una transacción automáticamente en el primer comando y requiere un mensaje explícito commit():

conn = psycopg2.connect(...)
cursor = conn.cursor()
cursor.execute("INSERT INTO ...")
conn.commit() 

El mssql-python controlador funciona igual por defecto. El autocommit está desactivado y llamas a commit() explícitamente:

conn = mssql_python.connect(...)
cursor = conn.cursor()
cursor.execute("INSERT INTO ...")
conn.commit()

Para habilitar el autocommit:

Psycopg2:

conn = psycopg2.connect(...)
conn.autocommit = True

MSSQL-Python:

conn = mssql_python.connect(..., autocommit=True)
# or: conn.autocommit = True

Consulta Gestión de transacciones para obtener información sobre niveles de aislamiento, puntos de recuperación y patrones de reintento en caso de interbloqueo.

Consideraciones de tipo

Las siguientes secciones cubren las diferencias más comunes en el mapeo de tipos entre PostgreSQL y Microsoft SQL.

JSON

PostgreSQL tiene operadores nativos JSONB con indexación y consulta (->, ->>, @>). Microsoft SQL almacena JSON como nvarchar(max) y proporciona funciones para consultar:

PostgreSQL SQL Server
data->>'name' JSON_VALUE(data, '$.name')
data->'items' JSON_QUERY(data, '$.items')
data @> '{"active": true}' JSON_VALUE(data, '$.active') = 'true'
jsonb_array_length(data) (SELECT COUNT(*) FROM OPENJSON(data))

En Python, ambos enfoques usan json.dumps() para serializar:

import json

cursor.execute(
    "INSERT INTO #Settings ([key], data) VALUES (%(key)s, %(data)s)",
    {"key": "config", "data": json.dumps({"theme": "dark", "lang": "en"})}
)

Consulte datos JSON para una guía completa sobre patrones de almacenamiento y consulta JSON.

Identificador Único Universal (UUID)

Tanto PostgreSQL como mssql-python se mapean uuid.UUID de forma nativa:

import uuid

cursor.execute(
    "INSERT INTO #Events (EventID, Name) VALUES (%(event_id)s, %(name)s)",
    {"event_id": uuid.uuid4(), "name": "signup"}
)

Consulta Configuración del módulo para la opción de conexión native_uuid.

Fecha, hora y zona horaria

El TIMESTAMPTZ de PostgreSQL se convierte a UTC al almacenarlo. Microsoft SQL datetimeoffset conserva el desplazamiento original:

from datetime import datetime, timezone, timedelta

eastern = timezone(timedelta(hours=-5))
dt = datetime(2025, 6, 15, 14, 30, tzinfo=eastern)

# PostgreSQL stores as UTC: 2025-06-15 19:30:00+00
# SQL Server stores as-is: 2025-06-15 14:30:00-05:00
cursor.execute("INSERT INTO #Events (EventTime) VALUES (%(event_time)s)", {"event_time": dt})

Si necesitas almacenamiento UTC consistente, convierte en Python antes de insertar:

dt_utc = dt.astimezone(timezone.utc)
cursor.execute("INSERT INTO #Events (EventTime) VALUES (%(event_time)s)", {"event_time": dt_utc})

Consulta el manejo de fecha y hora para ver el mapeo completo de tipos.

Arreglos

PostgreSQL soporta columnas nativas de arrays (INTEGER[], TEXT[]). Microsoft SQL no tiene un tipo de array. Alternativas comunes:

  1. Tabla separada (normalizada). Lo mejor para datos indexados y consultables.
  2. Array JSON almacenado en nvarchar(max). Bueno para metadatos opacos.
  3. Cadena separada por comas con STRING_SPLIT(). Simple pero limitado.
# Option 1: Normalized table
cursor.execute("INSERT INTO #ProductTags (ProductID, Tag) VALUES (%(product_id)s, %(tag)s)", {"product_id": 1, "tag": "electronics"})
cursor.execute("INSERT INTO #ProductTags (ProductID, Tag) VALUES (%(product_id)s, %(tag)s)", {"product_id": 1, "tag": "sale"})

# Option 2: JSON array
import json
tags = json.dumps(["electronics", "sale"])
cursor.execute("INSERT INTO #Products (Name, Tags) VALUES (%(name)s, %(tags)s)", {"name": "Widget", "tags": tags})

Unicode

PostgreSQL almacena todo el texto como UTF-8 por defecto. Microsoft SQL distingue entre varchar (codificación de páginas de códigos) y nvarchar (UTF-16). El mssql-python controlador envía valores de Python str como nvarchar por defecto, así que el texto Unicode funciona sin configuración adicional. Si tu esquema usa columnas varchar y necesitas evitar la conversión implícita, usa setinputsizes() para especificar el tipo de columna. Consulta String y datos Unicode para detalles de codificación.

Carga masiva y movimiento de datos

PostgreSQL utiliza COPY para operaciones en bloque. MSSQL-Python proporciona bulkcopy():

Psycopg2:

with open("data.csv") as f:
    cursor.copy_expert("COPY products FROM STDIN CSV HEADER", f)

MSSQL-Python:

import csv

with open("data.csv", newline="") as f:
    reader = csv.reader(f)
    next(reader)  # Skip header
    rows = [tuple(row) for row in reader]

cursor.bulkcopy("##Products", rows)

Para archivos grandes, utiliza un generador para evitar cargar todo el archivo en memoria:

import csv

def csv_rows(path):
    with open(path, newline="") as f:
        reader = csv.reader(f)
        next(reader)  # Skip header
        for row in reader:
            yield tuple(row)

cursor.bulkcopy("##Products", csv_rows("data.csv"), batch_size=5000)

Consulta Operaciones de copia masiva para mapeos de columnas, gestión de identidad y consejos de rendimiento.

Migración de esquemas y datos

Utiliza este enfoque para migrar una base de datos PostgreSQL existente:

  1. Exporta el esquema. Use pg_dump --schema-only para obtener DDL. Para detalles de opciones y casos límite (propiedad, privilegios, extensiones y filtrado), véase la referencia de PostgreSQLpg_dump. Reescribe el DDL usando la tabla de diferencias dialectales SQL .
  2. Crear tablas en Microsoft SQL. Ejecuta el DDL reescrito contra tu base de datos objetivo.
  3. Exportar datos. Utiliza pg_dump --data-only --format=csv o consulta cada tabla con psycopg2. Para conjuntos de datos grandes y switches de compatibilidad, revisa la documentación de PostgreSQLpg_dump, especialmente la sección de opciones.
  4. Carga datos con copia masiva. Lee el orden de las columnas de destino del catálogo para no codificar una lista de columnas por tabla, y luego integra cada tabla en Microsoft SQL. Este es un script de ejemplo:
import json
import psycopg2
from psycopg2 import sql
import mssql_python

pg_conn = psycopg2.connect(host="<pgserver>", dbname="<database>", user="<username>", password="<password>")
sql_conn = mssql_python.connect(
    server="<server>.database.windows.net",
    database="<database>",
    authentication="ActiveDirectoryDefault",
    encrypt="yes"
)

def table_columns(cursor, table):
    """Return the ordered column names and identity column from the catalog."""
    cursor.execute(
        "SELECT c.name, c.is_identity FROM sys.columns AS c "
        "WHERE c.object_id = OBJECT_ID(?) ORDER BY c.column_id",
        (table,)
    )
    columns, identity = [], None
    for name, is_identity in cursor.fetchall():
        columns.append(name)
        if is_identity:
            identity = name
    return columns, identity

def parse_pg_table_name(qualified_name):
    """Split a PostgreSQL table name into schema and table parts."""
    if "." in qualified_name:
        schema_name, table_name = qualified_name.split(".", 1)
    else:
        schema_name, table_name = "public", qualified_name
    return schema_name, table_name

def parse_sql_table_name(qualified_name):
    """Split a SQL Server table name into schema and table parts."""
    if "." in qualified_name:
        schema_name, table_name = qualified_name.split(".", 1)
    else:
        schema_name, table_name = "dbo", qualified_name
    return schema_name, table_name

def dependency_order(pg_cursor, table_names, schema_name="public"):
    """Topologically sort tables by foreign key dependencies."""
    table_set = set(table_names)
    incoming = {name: 0 for name in table_set}
    edges = {name: set() for name in table_set}

    pg_cursor.execute(
        """
        SELECT
            child.relname AS child_table,
            parent.relname AS parent_table
        FROM pg_constraint c
        JOIN pg_class child ON c.conrelid = child.oid
        JOIN pg_namespace child_ns ON child.relnamespace = child_ns.oid
        JOIN pg_class parent ON c.confrelid = parent.oid
        JOIN pg_namespace parent_ns ON parent.relnamespace = parent_ns.oid
        WHERE c.contype = 'f'
          AND child_ns.nspname = %s
          AND parent_ns.nspname = %s
        """,
        (schema_name, schema_name),
    )

    for child, parent in pg_cursor.fetchall():
        if child in table_set and parent in table_set and child != parent:
            if child not in edges[parent]:
                edges[parent].add(child)
                incoming[child] += 1

    ready = sorted([name for name, degree in incoming.items() if degree == 0])
    ordered = []

    while ready:
        current = ready.pop(0)
        ordered.append(current)
        for neighbor in sorted(edges[current]):
            incoming[neighbor] -= 1
            if incoming[neighbor] == 0:
                ready.append(neighbor)
        ready.sort()

    # If cycles remain, process remaining tables alphabetically.
    if len(ordered) < len(table_set):
        remaining = sorted(table_set - set(ordered))
        ordered.extend(remaining)

    return ordered

def discover_table_pairs(pg_cursor, sql_cursor, pg_schema="public", sql_schema="dbo"):
    """Find tables that exist in both PostgreSQL and SQL Server, in dependency order."""
    pg_cursor.execute(
        """
        SELECT table_name
        FROM information_schema.tables
        WHERE table_schema = %s AND table_type = 'BASE TABLE'
        """,
        (pg_schema,),
    )
    pg_tables = {row[0] for row in pg_cursor.fetchall()}

    sql_cursor.execute(
        """
        SELECT t.name
        FROM sys.tables AS t
        JOIN sys.schemas AS s ON t.schema_id = s.schema_id
        WHERE s.name = ?
        """,
        (sql_schema,),
    )
    sql_tables = {row[0] for row in sql_cursor.fetchall()}

    common_tables = sorted(pg_tables & sql_tables)
    ordered_tables = dependency_order(pg_cursor, common_tables, schema_name=pg_schema)

    return [(f"{pg_schema}.{name}", f"{sql_schema}.{name}") for name in ordered_tables]

def source_columns(pg_cursor, source_table):
    """Return ordered source columns from PostgreSQL information_schema."""
    schema_name, table_name = parse_pg_table_name(source_table)
    pg_cursor.execute(
        """
        SELECT column_name
        FROM information_schema.columns
        WHERE table_schema = %s AND table_name = %s
        ORDER BY ordinal_position
        """,
        (schema_name, table_name),
    )
    return [row[0] for row in pg_cursor.fetchall()]

def migrate_table(pg_cursor, sql_cursor, source_table, dest_table):
    # The destination defines the authoritative column order for positional bulkcopy().
    dest_columns, identity = table_columns(sql_cursor, dest_table)
    if not dest_columns:
        raise RuntimeError(
            f"No destination columns found for {dest_table}. "
            "Make sure the destination table exists before migration."
        )

    src_columns = source_columns(pg_cursor, source_table)
    if not src_columns:
        raise RuntimeError(
            f"No source columns found for {source_table}. "
            "Check the source table name and schema."
        )

    # Load only columns present on both sides and keep destination column order.
    src_column_set = set(src_columns)
    load_columns = [c for c in dest_columns if c in src_column_set]
    if not load_columns:
        raise RuntimeError(
            f"No shared columns between {source_table} and {dest_table}."
        )

    source_schema, source_name = parse_pg_table_name(source_table)
    select_query = sql.SQL("SELECT {cols} FROM {schema}.{table}").format(
        cols=sql.SQL(", ").join(sql.Identifier(c) for c in load_columns),
        schema=sql.Identifier(source_schema),
        table=sql.Identifier(source_name),
    )
    pg_cursor.execute(select_query)

    copied = 0
    while True:
        batch = pg_cursor.fetchmany(10000)
        if not batch:
            break
        # Serialize JSONB or array values (dict/list) for nvarchar(max) columns.
        rows = [
            tuple(json.dumps(v) if isinstance(v, (dict, list)) else v for v in row)
            for row in batch
        ]
        # keep_identity preserves source primary keys so foreign keys still line up.
        result = sql_cursor.bulkcopy(
            dest_table,
            rows,
            batch_size=10000,
            keep_identity=identity in load_columns,
        )
        copied += result["rows_copied"]
    return copied

pg_cursor = pg_conn.cursor()
sql_cursor = sql_conn.cursor()

# Leave TABLE_MAPPINGS as None to migrate every table that exists in both schemas.
# To migrate only selected tables, replace None with explicit mappings.
TABLE_MAPPINGS = None

if TABLE_MAPPINGS is None:
    tables = discover_table_pairs(pg_cursor, sql_cursor, pg_schema="public", sql_schema="dbo")
else:
    tables = TABLE_MAPPINGS

if not tables:
    raise RuntimeError(
        "No shared tables found between source and destination schemas. "
        "Check schema names and table creation on SQL Server."
    )

print(f"Migrating {len(tables)} table(s)...")
for source_table, dest_table in tables:
    count = migrate_table(pg_cursor, sql_cursor, source_table, dest_table)
    print(f"{dest_table}: copied {count} rows")

# bulkcopy() bypasses constraint checks, so foreign keys are left untrusted.
# Re-validate each table to mark them trusted and surface any orphaned rows.
for _, dest_table in tables:
    dest_schema, dest_name = parse_sql_table_name(dest_table)
    sql_cursor.execute(
        f"ALTER TABLE [{dest_schema}].[{dest_name}] WITH CHECK CHECK CONSTRAINT ALL"
    )
sql_conn.commit()

pg_conn.close()
sql_conn.close()

Por defecto, este script migra todas las tablas que existen tanto public en (PostgreSQL) como dbo en (SQL Server), ordenadas por dependencias de clave foránea. Configura TABLE_MAPPINGS como una lista explícita si quieres migrar solo un subconjunto.

Esto asume que el origen y el destino usan los mismos nombres de columna, que es lo habitual después de reescribir el DDL. El ayudante gestiona automáticamente la columna identidad: keep_identity preserva las claves primarias de origen cuando el destino tiene una IDENTITY columna, de modo que las referencias de clave extranjera permanecen intactas. Para que SQL Server asigne nuevas claves en su lugar, excluye la columna identidad de columns y pasa keep_identity=False.

Claves y restricciones externas

bulkcopy() utiliza el protocolo TDS bulk insert, que no aplica restricciones de clave ajena ni de comprobación durante la carga. Sin una solicitud explícita para comprobarlos, SQL Server ignora CHECK y FOREIGN KEY restricciones durante una importación masiva y las marca como no confiables después, como se describe en BULK INSERT. Este comportamiento tiene dos consecuencias prácticas para la migración:

  • El orden de carga no importa. Puedes cargar una tabla hija antes que la tabla padre sin incurrir en violaciones de clave foránea. Conserva las claves primarias con keep_identity=True, como hace la función auxiliar, para que los valores de las claves principal y secundaria sigan coincidiendo tras la carga.
  • Las restricciones acaban siendo no confiables. Tras una carga masiva, cada clave extranjera se marca como no confiable (sys.foreign_keys.is_not_trusted = 1) porque SQL Server no la verificó. El paso final del script vuelve a validar cada tabla cargada con ALTER TABLE ... WITH CHECK CHECK CONSTRAINT ALL. Este paso marca las restricciones como de confianza para que el optimizador de consultas pueda utilizarlas y pone de manifiesto los datos erróneos. Si una fila hija hace referencia a un padre ausente, la sentencia falla con una violación de restricción de integridad que nombra la restricción, así que puedes arreglar las filas huérfanas antes de lanzarlas.

Limitations

Revisa estas diferencias antes de migrar:

Tema PostgreSQL mssql-python / SQL Server
callproc() Soportado Genera NotSupportedError. Utilice cursor.execute("EXECUTE ...") en su lugar.
Parámetros con valores de tabla (TVPs) Sin equivalente directo No está soportado en el controlador actual. Usa tablas temporales o JSON para parámetros de varias filas.
Columnas nativas ARRAY Soportado No hay ningún tipo de matriz. Utiliza tablas normalizadas, arrays JSON o STRING_SPLIT().
LISTEN/NOTIFY Soportado No hay equivalente directo. Utiliza Service Broker o encuestas a nivel de aplicación.
COPY Streaming Soportado Úsalo bulkcopy() para carga masiva de datos.
Devolución de las filas modificadas Cláusula RETURNING OUTPUT INSERTED / OUTPUT DELETED cláusula en sentencias DML.
Controlador asíncrono psycopg3 admite async de forma nativa mssql-python El soporte asincrónico está orientado a soluciones alternativas (pool de hilos).
Búsqueda de texto completo tsvector / tsquery CONTAINS() / FREETEXT() con índices de texto completo.
ORM (SQLAlchemy) Totalmente compatible Compatible a través del dialecto integrado mssql-python en SQLAlchemy 2.1.0b2+ (versión preliminar).

Lista de comprobación de validación

Utiliza esta lista de verificación para verificar tu migración:

  1. Sustituye todos los marcadores de parámetros %s por parámetros ? o %(name)s.
  2. Asegúrate de que todos %(name)s los parámetros sigan funcionando (ambos controladores soportan este formato).
  3. Reescribir LIMIT/OFFSET a .OFFSET/FETCH NEXT
  4. Reescribir RETURNING a OUTPUT INSERTED.
  5. Reescribir ON CONFLICT a MERGE.
  6. Sustituye SERIAL / BIGSERIAL por .IDENTITY
  7. BOOLEAN las columnas fueron reemplazadas por bit.
  8. Sustituye las columnas del array por tablas normalizadas o JSON.
  9. Sustituye los operadores JSONB por JSON_VALUE() / JSON_QUERY().
  10. Actualizar la cadena de conexión para la autenticación de Microsoft SQL.
  11. Prueba la aplicación contra AdventureWorks o tu esquema objetivo.

Autenticación y despliegue

Las aplicaciones PostgreSQL autogestionadas suelen desplegarse con cadenas de conexión que contienen contraseñas, o utilizan .pgpass archivos y PGPASSWORD variables de entorno. Azure Database for PostgreSQL soporta autenticación Microsoft Entra, así que si ya usas autenticación sin contraseña, el mismo modelo de identidad se traslada a Azure SQL.

Para cargas de trabajo de producción contra Azure SQL, usa la identidad gestionada:

conn = mssql_python.connect(
    server="<server>.database.windows.net",
    database="AdventureWorks",
    authentication="ActiveDirectoryMSI",
    encrypt="yes"
)

Para el desarrollo local y la CI, consulta Contenedor y desarrollo local para ver patrones de configuración de Docker, devcontainer y canalizaciones de CI.