13. Migrar desde SQL Server, y costes
Secciones
Ajustes de lectura
Ninguna voz para este idioma en este navegador.
Más voces
Código y términos técnicos en inglés
Desactivado: la voz de la página lo lee todo, sin pausa entre dos voces.
Ninguna voz para este idioma en este navegador.
Más voces
Más voces: Microsoft Edge ofrece voces naturales en línea, y Windows (Configuración › Hora e idioma › Voz), macOS y Android instalan otras.
Las lecciones anteriores comparaban PostgreSQL con SQL Server funcionalidad a funcionalidad. Esta traslada una base de datos. Parte de un SQL Server real: SQL Server 2025 en la imagen de contenedor de Microsoft mcr.microsoft.com/mssql/server:2025-CU9-ubuntu-24.04 (17.0.5005.3, digest sha256:2b5b581621126574f3d1f75e78d3eebe8d05aedb59ad0cfdf9aa42cb0634d726), con las mismas ejecuciones y jobs de GitHub Actions que el resto del curso. Después los datos van dos veces a PostgreSQL 18: con un programa C#, y con pgloader. Ambos se ejecutan en el modo mssql de check.sh y en la CI, donde SQL Server es un contenedor de servicio junto a PostgreSQL.
El código:
mssql.sharranca SQL Server y ejecutasqlcmddentro de él;mssql/13-source.sqlcrea la base de datos de origen;sql/mssql-target.sqlysql/mssql-target-after.sqlson el esquema convertido;csharp-mssql/L13.cscopia y valida;sql/13-migration.sqlmuestra lo que se comporta de otra forma una vez que los datos están en PostgreSQL;sql/pgloader-check.sqlexamina lo que creó pgloader.
Los servicios de migración de AWS no se pueden ejecutar en local, y el curso no usa ninguna cuenta de AWS: AWS DMS, Babelfish para Aurora PostgreSQL y los precios vienen de la documentación y la lista de precios de AWS, consultadas el 2026-09-16, y están por verificar.
| Herramienta de SQL Server | Para PostgreSQL y Aurora |
|---|---|
| Data Migration Assistant, SSMA | DMS Schema Conversion o la AWS Schema Conversion Tool; Babelfish Compass para evaluar el T-SQL de cara a Babelfish |
bcp, SqlBulkCopy, SSIS |
COPY desde un programa (lección 4), pgloader, carga completa de AWS DMS |
| replicación transaccional, captura de datos modificados | replicación continua de AWS DMS, que lee MS-Replication o MS-CDC de SQL Server |
| una aplicación T-SQL trasladada tal cual | Babelfish: Aurora PostgreSQL hablando TDS en el puerto 1433 |
La base de datos que migrar
Sección titulada «La base de datos que migrar»bash mssql.sh start ejecuta el contenedor; SQL Server en Linux necesita «at least 2 GB of RAM» (inicio rápido de Docker), y mssql.sh lo limita a 2048 MB con MSSQL_MEMORY_LIMIT_MB. El esquema de origen es lo que podría haber escrito un equipo de SQL Server: nombres en PascalCase, nvarchar, tinyint, tres tipos de fecha, una columna calculada, rowversion, una clave IDENTITY, un uniqueidentifier, money (mssql/13-source.sql, líneas 12-66):
CREATE TABLE dbo.Runs ( RunId bigint NOT NULL PRIMARY KEY, WorkflowName nvarchar(100) NOT NULL, Event varchar(30) NOT NULL, Conclusion varchar(20) NULL, HeadBranch nvarchar(255) NOT NULL, HeadSha char(40) NOT NULL, Attempt tinyint NOT NULL CHECK (Attempt >= 1), CreatedAt datetime2(0) NOT NULL, StartedAt datetime NOT NULL, UpdatedAt datetimeoffset(7) NOT NULL, IsRerun AS CAST(CASE WHEN Attempt > 1 THEN 1 ELSE 0 END AS bit));
CREATE TABLE dbo.Jobs ( JobId bigint NOT NULL PRIMARY KEY, RunId bigint NOT NULL REFERENCES dbo.Runs, Name nvarchar(200) NOT NULL, Conclusion varchar(20) NULL, RunnerName nvarchar(100) NULL, StartedAt datetime2(7) NOT NULL, CompletedAt datetime2(7) NOT NULL, RowVer rowversion);
-- Una fila por etiqueta: SQL Server no tiene tipo arrayCREATE TABLE dbo.JobLabels ( JobId bigint NOT NULL REFERENCES dbo.Jobs, Label nvarchar(100) NOT NULL, CONSTRAINT PK_JobLabels PRIMARY KEY (JobId, Label));
CREATE TABLE dbo.Steps ( JobId bigint NOT NULL REFERENCES dbo.Jobs, Number smallint NOT NULL, Name nvarchar(200) NOT NULL, Conclusion varchar(20) NULL, StartedAt datetime2(0) NOT NULL, CompletedAt datetime2(0) NOT NULL, PRIMARY KEY (JobId, Number));
-- Notas escritas por personas, con los tipos que suele tener un esquema SQL Server: una clave IDENTITY, un GUID, money, datetimeCREATE TABLE dbo.Notes ( NoteId int IDENTITY(1, 1) PRIMARY KEY, NoteGuid uniqueidentifier NOT NULL UNIQUE, RunId bigint NULL REFERENCES dbo.Runs, Author nvarchar(50) NOT NULL, Tag varchar(20) NULL CONSTRAINT UQ_Notes_Tag UNIQUE, Body nvarchar(max) NOT NULL, Cost money NULL, WrittenAt datetime NOT NULL, WrittenLocal datetimeoffset(7) NOT NULL);CREATE INDEX IX_Jobs_RunId ON dbo.Jobs (RunId) INCLUDE (Conclusion);Las ejecuciones, los jobs y los pasos vienen de los mismos archivos JSON que en la lección 2, leídos con OPENROWSET(BULK …) y OPENJSON; tres notas se escriben a mano, para contener los valores que no sobreviven intactos a una migración (líneas 98-104):
INSERT INTO dbo.Notes (NoteGuid, RunId, Author, Tag, Body, Cost, WrittenAt, WrittenLocal) VALUES ('6F9619FF-8B86-D011-B42D-00C04FC964FF', 34852867099, N'Reviewer', 'Slow', N'Deploy took 2.5 minutes', 0.0125, '2026-09-14 14:05:00.001', '2026-09-14 16:05:00.1234567 +02:00'), ('00000000-0000-0000-0000-000000000001', 34852867099, N'reviewer ', NULL, N'Looks fine now', NULL, '2026-09-14 14:06:00.005', '2026-09-14 10:06:00.9999999 -04:00'), ('01000000-0000-0000-0000-000000000000', NULL, N'REVIEWER', 'flaky', N'Café au lait', 3.5, '2026-09-14 14:07:00.998', '2026-09-14 14:07:00 +00:00');La salida empieza con la collation de la base de datos y los recuentos de filas (expected/mssql-13-source.txt):
Changed database context to 'master'.Changed database context to 'ci'.collation---------SQL_Latin1_General_CP1_CI_AStable|rows-----|----Runs|125Jobs|315JobLabels|315Steps|2204Notes|3sqlcmd se ejecuta con -W -s '|': columnas recortadas separadas por barras. SQL_Latin1_General_CP1_CI_AS es la collation por defecto de una instalación en inglés de EE. UU. (collations): no distingue mayúsculas (CI) y distingue acentos (AS).
Siete comportamientos que comprobar antes de trasladar los datos
Sección titulada «Siete comportamientos que comprobar antes de trasladar los datos»Las mismas preguntas, hechas a SQL Server (líneas 114-142):
-- datetime guarda 1/300 de segundo: .001 se guardó como .000, .005 como .007SELECT NoteId, WrittenAt, WrittenLocal FROM dbo.Notes ORDER BY NoteId;
-- La collation por defecto ignora las mayúsculas, y = ignora los espacios finalesSELECT COUNT(DISTINCT Author) AS authors, SUM(CASE WHEN Author = N'reviewer' THEN 1 ELSE 0 END) AS [= 'reviewer']FROM dbo.Notes;BEGIN TRY INSERT INTO dbo.Notes (NoteGuid, Author, Tag, Body, WrittenAt, WrittenLocal) VALUES (NEWID(), N'Reviewer', 'SLOW', N'again', '2026-09-14', '2026-09-14 00:00 +00:00');END TRYBEGIN CATCH SELECT ERROR_NUMBER() AS error, ERROR_MESSAGE() AS message;END CATCH;-- Una restricción UNIQUE acepta un NULL, no dosBEGIN TRY INSERT INTO dbo.Notes (NoteGuid, Author, Tag, Body, WrittenAt, WrittenLocal) VALUES (NEWID(), N'Reviewer', NULL, N'again', '2026-09-14', '2026-09-14 00:00 +00:00');END TRYBEGIN CATCH SELECT ERROR_NUMBER() AS error, ERROR_MESSAGE() AS message;END CATCH;
-- uniqueidentifier ordena primero por sus seis últimos bytes; NULL va primeroSELECT NoteGuid, Cost FROM dbo.Notes ORDER BY NoteGuid;SELECT NoteId, Cost FROM dbo.Notes ORDER BY Cost;
-- + con NULL da NULL, CONCAT lo ignora; TOP e ISNULLSELECT TOP (2) NoteId, Tag + N': ' + Body AS plus, CONCAT(Tag, N': ', Body) AS concat, ISNULL(Tag, 'none') AS tagFROM dbo.Notes ORDER BY NoteId;NoteId|WrittenAt|WrittenLocal------|---------|------------1|2026-09-14 14:05:00.000|2026-09-14 16:05:00.1234567 +02:002|2026-09-14 14:06:00.007|2026-09-14 10:06:00.9999999 -04:003|2026-09-14 14:07:00.997|2026-09-14 14:07:00.0000000 +00:00authors|= 'reviewer'-------|------------1|3error|message-----|-------2627|Violation of UNIQUE KEY constraint 'UQ_Notes_Tag'. Cannot insert duplicate key in object 'dbo.Notes'. The duplicate key value is (SLOW).error|message-----|-------2627|Violation of UNIQUE KEY constraint 'UQ_Notes_Tag'. Cannot insert duplicate key in object 'dbo.Notes'. The duplicate key value is (<NULL>).NoteGuid|Cost--------|----01000000-0000-0000-0000-000000000000|3.500000000000-0000-0000-0000-000000000001|NULL6F9619FF-8B86-D011-B42D-00C04FC964FF|.0125NoteId|Cost------|----2|NULL1|.01253|3.5000NoteId|plus|concat|tag------|----|------|---1|Slow: Deploy took 2.5 minutes|Slow: Deploy took 2.5 minutes|Slow2|NULL|: Looks fine now|noneY a PostgreSQL, sobre la tabla convertida de la sección siguiente, con las notas tal como las copia el programa (sql/13-migration.sql, líneas 23-50). El script añade primero una cuarta nota, la número 7, para la sección sobre la identidad:
-- timestamptz conserva el instante, no el desfase: cada valor vuelve en la zona horaria de la sesiónSELECT note_id, written_at, written_local FROM migrated.notes ORDER BY note_id;
-- La collation ci ignora las mayúsculas, pero no los espacios finales: tres autores pasan a ser dosSELECT count(DISTINCT author) AS authors, count(*) FILTER (WHERE author = 'reviewer') AS "= 'reviewer'"FROM migrated.notes;-- La misma columna comparada con la collation por defecto de la base de datosSELECT count(DISTINCT author COLLATE "default") AS authors FROM migrated.notes;-- rtrim donde importaban las reglas de relleno de SQL ServerSELECT count(DISTINCT rtrim(author)) AS authors FROM migrated.notes;
-- LIKE sobre una collation no determinista: nuevo en PostgreSQL 18SELECT note_id, author FROM migrated.notes WHERE author LIKE 'rev%' ORDER BY note_id;
-- La restricción única también compara las etiquetas con la collation ci, y acepta un solo NULLINSERT INTO migrated.notes (note_guid, author, tag, body, written_at, written_local)VALUES (gen_random_uuid(), 'Reviewer', 'SLOW', 'again', '2026-09-14', '2026-09-14 00:00+00');INSERT INTO migrated.notes (note_guid, author, tag, body, written_at, written_local)VALUES (gen_random_uuid(), 'Reviewer', NULL, 'again', '2026-09-14', '2026-09-14 00:00+00');
-- uuid ordena byte a byte, desde la izquierda; NULL va al final en orden ascendenteSELECT note_guid, cost FROM migrated.notes ORDER BY note_guid;SELECT note_id, cost FROM migrated.notes ORDER BY cost;SELECT note_id, cost FROM migrated.notes ORDER BY cost NULLS FIRST;
-- || con NULL da NULL, concat lo ignora; FETCH FIRST ... WITH TIES, y coalesce en lugar de ISNULLSELECT note_id, tag || ': ' || body AS plus, concat(tag, ': ', body) AS concat, coalesce(tag, 'none') AS tagFROM migrated.notes ORDER BY note_id FETCH FIRST 2 ROWS ONLY; note_id | written_at | written_local---------+-------------------------+------------------------------- 1 | 2026-09-14 14:05:00 | 2026-09-14 14:05:00.123456+00 2 | 2026-09-14 14:06:00.007 | 2026-09-14 14:06:00.999999+00 3 | 2026-09-14 14:07:00.997 | 2026-09-14 14:07:00+00 7 | 2026-09-16 00:00:00 | 2026-09-16 00:00:00+00(4 rows)
authors | = 'reviewer'---------+-------------- 2 | 3(1 row)
authors--------- 3(1 row)
authors--------- 1(1 row)
note_id | author---------+----------- 1 | Reviewer 2 | reviewer 3 | REVIEWER 7 | Reviewer(4 rows)
ERROR: duplicate key value violates unique constraint "notes_tag_key"DETAIL: Key (tag)=(SLOW) already exists.ERROR: duplicate key value violates unique constraint "notes_tag_key"DETAIL: Key (tag)=(null) already exists. note_guid | cost--------------------------------------+-------- 00000000-0000-0000-0000-000000000001 | 01000000-0000-0000-0000-000000000000 | 3.5000 6f9619ff-8b86-d011-b42d-00c04fc964ff | 0.0125 70000000-0000-0000-0000-000000000000 |(4 rows)
note_id | cost---------+-------- 1 | 0.0125 3 | 3.5000 2 | 7 |(4 rows)
note_id | cost---------+-------- 2 | 7 | 1 | 0.0125 3 | 3.5000(4 rows)
note_id | plus | concat | tag---------+-------------------------------+-------------------------------+------ 1 | Slow: Deploy took 2.5 minutes | Slow: Deploy took 2.5 minutes | Slow 2 | | : Looks fine now | none(2 rows)datetimeno es preciso al milisegundo. Sus valores están «rounded to increments of .000, .003, or .007 seconds» (datetime):.001se guardó como.000,.005como.007,.998como.997. La migración copia los valores redondeados; nada puede recuperar los originales.timestamptzconserva el instante, no el desfase.datetimeoffsetconservaba+02:00y-04:00; PostgreSQL devuelve cada valor en la zona horaria de la sesión, aquí UTC. La documentación de Npgsql dice lo mismo: «only a UTC timestamp is stored» (tipos de fecha y hora). Si el desfase de quien escribió importa, necesita su propia columna.- Mayúsculas. Con la collation por defecto de la base de datos,
Reviewer,revieweryREVIEWERson tres autores. El destino declara una collation ICU no determinista,und-u-ks-level2, que compara sin distinguir mayúsculas: pasan a ser dos. - Espacios finales. «Transact-SQL considers the strings ‘abc’ and ’abc ’ to be equivalent for most comparison operations» (comparación de cadenas), así que SQL Server cuenta un solo autor. En
varcharytextde PostgreSQL el espacio sigue siendo significativo;rtrimdevuelve el recuento de SQL Server. La extensióncitexttambién ignora las mayúsculas, pero su documentación dice ahora: «Consider using nondeterministic collations … instead of this module.» LIKEsobre una columna que no distingue mayúsculas. Funciona en PostgreSQL 18: «Allow LIKE with nondeterministic collations» (notas de versión).ILIKE«does not support nondeterministic collations» (coincidencia de patrones).- Restricciones únicas y
NULL. SQL Server rechazóSLOWjunto aSlow, y una segunda etiquetaNULL. PostgreSQL trata losNULLcomo distintos en una restricción única «unless NULLS NOT DISTINCT is specified» (CREATE TABLE): el destino lo declara, junto con la collationci, para mantener ambas reglas. - Órdenes. En
uniqueidentifier, «ordering is not implemented by comparing the bit patterns of the two values» (uniqueidentifier): SQL Server compara primero «the last six bytes of a value» (comparar GUID), eluuidde PostgreSQL byte a byte desde la izquierda, así que las tres notas salen en otro orden. SQL Server ordenaNULLprimero, como «the lowest possible values» (ORDER BY); PostgreSQL lo ordena al final en orden ascendente, salvo conNULLS FIRST. Una paginación por conjunto de claves sobre un GUID, o un informe que espera losNULLarriba, cambia sin avisar.
La última consulta es la misma en ambos: + y || dan NULL cuando un lado es NULL, CONCAT y concat lo omiten; ISNULL pasa a ser coalesce, TOP (2) pasa a ser FETCH FIRST 2 ROWS ONLY.
Transacciones después de un error
Sección titulada «Transacciones después de un error»SQL Server y PostgreSQL no coinciden en lo que un error le hace a una transacción (líneas 145-172):
-- Sin SET XACT_ABORT, una instrucción fallida no termina la transacción: XACT_STATE() sigue valiendo 1, y COMMIT-- conserva las filas insertadas alrededor. (El error se captura: sqlcmd 18.6 descarta los resultados tras un error no capturado.)BEGIN TRANSACTION;INSERT INTO dbo.JobLabels (JobId, Label) VALUES (104004920113, N'self-hosted');BEGIN TRY INSERT INTO dbo.JobLabels (JobId, Label) VALUES (104004920113, N'ubuntu-latest');END TRYBEGIN CATCH SELECT ERROR_MESSAGE() AS message, XACT_STATE() AS xact_state;END CATCH;INSERT INTO dbo.JobLabels (JobId, Label) VALUES (104004920113, N'linux');COMMIT;SELECT Label FROM dbo.JobLabels WHERE JobId = 104004920113 ORDER BY Label;DELETE FROM dbo.JobLabels WHERE JobId = 104004920113 AND Label <> N'ubuntu-latest';
-- Con SET XACT_ABORT ON, el mismo error condena la transacción: XACT_STATE() vale -1, solo cabe un rollbackSET XACT_ABORT ON;BEGIN TRANSACTION;BEGIN TRY INSERT INTO dbo.JobLabels (JobId, Label) VALUES (104004920113, N'self-hosted'); INSERT INTO dbo.JobLabels (JobId, Label) VALUES (104004920113, N'ubuntu-latest');END TRYBEGIN CATCH SELECT XACT_STATE() AS xact_state; ROLLBACK;END CATCH;SET XACT_ABORT OFF;SELECT COUNT(*) AS labels FROM dbo.JobLabels WHERE JobId = 104004920113;message|xact_state-------|----------Violation of PRIMARY KEY constraint 'PK_JobLabels'. Cannot insert duplicate key in object 'dbo.JobLabels'. The duplicate key value is (104004920113, ubuntu-latest).|1Label-----linuxself-hostedubuntu-latestxact_state-----------1labels------1Con SET XACT_ABORT en su valor por defecto, «OFF is the default setting in a T-SQL statement», una clave duplicada termina la instrucción, no la transacción: XACT_STATE() sigue valiendo 1 y COMMIT conserva self-hosted y linux. Con XACT_ABORT ON, la transacción solo puede revertirse. El error se captura con TRY … CATCH porque sqlcmd 18.6 descartaba todos los resultados posteriores a un error no capturado en un script (ver el diario).
PostgreSQL se comporta como XACT_ABORT ON, y de forma más estricta (sql/13-migration.sql, líneas 52-71):
-- Un error dentro de una transacción aborta la transacción entera: la instrucción siguiente se rechaza, COMMIT revierteBEGIN;INSERT INTO migrated.notes (note_guid, author, tag, body, written_at, written_local)VALUES ('10000000-0000-0000-0000-000000000000', 'Reviewer', 'retry', 'kept?', '2026-09-16', '2026-09-16 00:00+00');INSERT INTO migrated.notes (note_guid, author, tag, body, written_at, written_local)VALUES (gen_random_uuid(), 'Reviewer', 'Retry', 'duplicate tag', '2026-09-16', '2026-09-16 00:00+00');SELECT count(*) FROM migrated.notes;COMMIT;SELECT count(*) AS kept FROM migrated.notes WHERE tag = 'retry';
-- Un savepoint devuelve el comportamiento de SQL Server para una instrucciónBEGIN;INSERT INTO migrated.notes (note_guid, author, tag, body, written_at, written_local)VALUES ('10000000-0000-0000-0000-000000000000', 'Reviewer', 'retry', 'kept', '2026-09-16', '2026-09-16 00:00+00');SAVEPOINT duplicate;INSERT INTO migrated.notes (note_guid, author, tag, body, written_at, written_local)VALUES (gen_random_uuid(), 'Reviewer', 'Retry', 'duplicate tag', '2026-09-16', '2026-09-16 00:00+00');ROLLBACK TO SAVEPOINT duplicate;COMMIT;SELECT count(*) AS kept FROM migrated.notes WHERE tag = 'retry';BEGININSERT 0 1ERROR: duplicate key value violates unique constraint "notes_tag_key"DETAIL: Key (tag)=(Retry) already exists.ERROR: current transaction is aborted, commands ignored until end of transaction blockROLLBACK kept------ 0(1 row)
BEGININSERT 0 1SAVEPOINTERROR: duplicate key value violates unique constraint "notes_tag_key"DETAIL: Key (tag)=(Retry) already exists.ROLLBACKCOMMIT kept------ 1(1 row)Tras el error, se rechaza cada instrucción hasta el final de la transacción, y COMMIT responde ROLLBACK. El código que contaba con que SQL Server siguiera tras una instrucción fallida necesita un SAVEPOINT alrededor de esa instrucción. Un driver que envía de todos modos la instrucción siguiente recibe el SQLSTATE 25P02, in_failed_sql_transaction (códigos de error).
Convertir el esquema a mano
Sección titulada «Convertir el esquema a mano»El esquema de destino (sql/mssql-target.sql):
-- Lección 13: las tablas de mssql/13-source.sql convertidas a mano, antes de la carga. Nombres en snake_case, para que-- no necesiten comillas; claves, pero aún sin claves foráneas ni índices secundarios (los añade mssql-target-after.sql).DROP SCHEMA IF EXISTS migrated CASCADE;CREATE SCHEMA migrated;
-- SQL_Latin1_General_CP1_CI_AS compara sin distinguir mayúsculas; esta collation ICU hace lo mismo, y no es determinista:-- dos cadenas pueden ser iguales sin tener los mismos bytesCREATE COLLATION migrated.ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false);
CREATE TABLE migrated.runs ( run_id bigint PRIMARY KEY, workflow_name varchar(100) NOT NULL, event varchar(30) NOT NULL, conclusion varchar(20), head_branch varchar(255) NOT NULL, head_sha char(40) NOT NULL, -- tinyint va de 0 a 255; PostgreSQL no tiene entero de un byte attempt smallint NOT NULL CHECK (attempt BETWEEN 1 AND 255), -- datetime2, datetime y datetimeoffset contienen todos horas UTC aquí: timestamptz created_at timestamptz(0) NOT NULL, started_at timestamptz(3) NOT NULL, updated_at timestamptz NOT NULL, -- una columna calculada no persistida: una columna generada virtual (PostgreSQL 18) is_rerun boolean GENERATED ALWAYS AS (attempt > 1) VIRTUAL);
CREATE TABLE migrated.jobs ( job_id bigint PRIMARY KEY, run_id bigint NOT NULL, name varchar(200) NOT NULL, conclusion varchar(20), runner_name varchar(100), started_at timestamptz NOT NULL, completed_at timestamptz NOT NULL -- sin rowversion: la columna de sistema xmin hace ese papel para la concurrencia optimista);
CREATE TABLE migrated.job_labels ( job_id bigint NOT NULL, label varchar(100) NOT NULL, PRIMARY KEY (job_id, label));
CREATE TABLE migrated.steps ( job_id bigint NOT NULL, number smallint NOT NULL, name varchar(200) NOT NULL, conclusion varchar(20), started_at timestamptz(0) NOT NULL, completed_at timestamptz(0) NOT NULL, PRIMARY KEY (job_id, number));
CREATE TABLE migrated.notes ( -- BY DEFAULT, para que la carga pueda conservar los valores del origen note_id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, note_guid uuid NOT NULL UNIQUE, run_id bigint, author varchar(50) COLLATE migrated.ci NOT NULL, -- NULLS NOT DISTINCT: un NULL como máximo, como en SQL Server tag varchar(20) COLLATE migrated.ci UNIQUE NULLS NOT DISTINCT, body text NOT NULL, cost numeric(19, 4), -- datetime no tiene zona horaria: timestamp, al milisegundo written_at timestamp(3) NOT NULL, -- datetimeoffset conserva el desfase de quien escribe; timestamptz solo conserva el instante written_local timestamptz NOT NULL);Las decisiones, tipo de columna por tipo de columna:
| SQL Server | PostgreSQL aquí | Por qué |
|---|---|---|
nombres PascalCase |
snake_case |
los identificadores sin comillas se pasan a minúsculas (lección 1); "RunId" necesitaría comillas en todas partes |
nvarchar(n), varchar(n) |
varchar(n), text |
las cadenas de PostgreSQL son todas Unicode (aquí UTF-8); la longitud es una restricción, no una elección de almacenamiento |
char(40) |
char(40) |
la misma semántica de relleno para un hash de longitud fija |
tinyint |
smallint con un CHECK |
no hay entero de un byte |
columna calculada bit |
boolean GENERATED ALWAYS AS (…) VIRTUAL |
una columna generada virtual, nueva en PostgreSQL 18 y ahora «the default kind» (columnas generadas) |
datetime2(0), datetime2(7) |
timestamptz(0), timestamptz |
valores UTC; la resolución de PostgreSQL es «1 microsecond» (tipos de fecha/hora), la de datetime2 es de 100 ns |
datetime |
timestamp(3), timestamptz(3) |
el tipo no tiene zona horaria: timestamp si los valores son locales, timestamptz si están en UTC |
datetimeoffset(7) |
timestamptz |
el desfase se pierde |
money |
numeric(19, 4) |
el money de PostgreSQL tiene su «fractional precision … determined by the database’s lc_monetary setting» (tipos monetarios) |
uniqueidentifier |
uuid |
los mismos 16 bytes, otro orden |
rowversion |
nada | para la concurrencia optimista, el proveedor EF Core de Npgsql usa la columna de sistema xmin (tokens de concurrencia) |
int IDENTITY(1, 1) |
integer GENERATED BY DEFAULT AS IDENTITY |
BY DEFAULT, para que la carga pueda escribir las claves del origen |
collation CI_AS |
una collation ICU no determinista, en las columnas que la necesitan | una collation no determinista cuesta rendimiento, «B-tree cannot use deduplication with indexes that use a nondeterministic collation» (collations) |
Las claves foráneas y los índices secundarios llegan después de la carga (sql/mssql-target-after.sql):
-- Lección 13: lo que recibe el destino tras la carga. Las claves foráneas se comprueban una vez, sobre las filas cargadas;-- los índices se construyen una vez, en lugar de actualizarse fila a fila.ALTER TABLE migrated.jobs ADD FOREIGN KEY (run_id) REFERENCES migrated.runs;ALTER TABLE migrated.job_labels ADD FOREIGN KEY (job_id) REFERENCES migrated.jobs;ALTER TABLE migrated.steps ADD FOREIGN KEY (job_id) REFERENCES migrated.jobs;ALTER TABLE migrated.notes ADD FOREIGN KEY (run_id) REFERENCES migrated.runs;-- IX_Jobs_RunId ... INCLUDE (Conclusion) tiene la misma sintaxisCREATE INDEX jobs_run_id ON migrated.jobs (run_id) INCLUDE (conclusion);ANALYZE migrated.runs, migrated.jobs, migrated.job_labels, migrated.steps, migrated.notes;Copiar los datos con C#
Sección titulada «Copiar los datos con C#»Microsoft.Data.SqlClient 7.0.3 lee cada tabla; el COPY binario de Npgsql (lección 4) la escribe. Cada tabla tiene un SELECT, un COPY y una función que escribe las columnas de una fila con tipos explícitos (L13.cs, líneas 19-23 y 76-135):
// Una tabla que copiar: el SELECT en SQL Server, el COPY en PostgreSQL, y cómo se escribe cada columna record Table(string Name, string Select, string Copy, Action<SqlDataReader, NpgsqlBinaryImporter> Write);
// datetime y datetime2 vuelven con DateTimeKind.Unspecified; Npgsql solo escribe en timestamptz valores DateTime en UTC static DateTime Utc(SqlDataReader r, int i) => DateTime.SpecifyKind(r.GetDateTime(i), DateTimeKind.Utc); new("notes", "SELECT NoteId, NoteGuid, RunId, Author, Tag, Body, Cost, WrittenAt, WrittenLocal FROM dbo.Notes", "COPY migrated.notes (note_id, note_guid, run_id, author, tag, body, cost, written_at, written_local) FROM STDIN (FORMAT BINARY)", (r, w) => { w.Write(r.GetInt32(0), NpgsqlDbType.Integer); w.Write(r.GetGuid(1), NpgsqlDbType.Uuid); WriteNullable(w, r.IsDBNull(2) ? null : r.GetInt64(2), NpgsqlDbType.Bigint); w.Write(r.GetString(3), NpgsqlDbType.Varchar); WriteNullable(w, r.IsDBNull(4) ? null : r.GetString(4), NpgsqlDbType.Varchar); w.Write(r.GetString(5), NpgsqlDbType.Text); WriteNullable(w, r.IsDBNull(6) ? null : r.GetDecimal(6), NpgsqlDbType.Numeric); // timestamp: sin zona horaria en ningún lado, el DateTime se escribe tal cual w.Write(r.GetDateTime(7), NpgsqlDbType.Timestamp); w.Write(r.GetDateTimeOffset(8).UtcDateTime, NpgsqlDbType.TimestampTz); }), ];
static void WriteNullable(NpgsqlBinaryImporter w, object? value, NpgsqlDbType type) { if (value is null) { w.WriteNull(); } else { w.Write(value, type); } }
public static async Task Migrate() { await using var source = new SqlConnection(SqlServer); await source.OpenAsync(); await using var target = Db.DataSource();
await Execute(target, await File.ReadAllTextAsync("sql/mssql-target.sql")); foreach (var table in Tables) { await using var connection = await target.OpenConnectionAsync(); await using var importer = await connection.BeginBinaryImportAsync(table.Copy); await using var command = new SqlCommand(table.Select, source); await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { await importer.StartRowAsync(); table.Write(reader, importer); } Console.WriteLine($"{table.Name}: {await importer.CompleteAsync()} rows copied"); } await Execute(target, await File.ReadAllTextAsync("sql/mssql-target-after.sql"));
// La columna de identidad continúa tras el último valor de SQL Server, no tras la mayor clave copiada await using (var command = new SqlCommand("SELECT IDENT_CURRENT('dbo.Notes')", source)) { var last = Convert.ToInt64(await command.ExecuteScalarAsync()); await Execute(target, $"ALTER TABLE migrated.notes ALTER COLUMN note_id RESTART WITH {last + 1}"); Console.WriteLine($"notes.note_id restarts with {last + 1}"); } }runs: 125 rows copiedjobs: 315 rows copiedjob_labels: 315 rows copiedsteps: 2204 rows copiednotes: 3 rows copiednotes.note_id restarts with 7DateTime.Kind. SqlClient devuelve los valoresdatetimeydatetime2comoDateTimede tipo no especificado; «Npgsql maps UTC DateTime to timestamp with time zone» (tipos de fecha y hora), así que el programa indica que están en UTC conDateTime.SpecifyKind.datetimeoffsetpasa a serDateTimeOffset.UtcDateTime.- Un proyecto aparte. El programa está en
csharp-mssql, no con los demás ejemplos: encsharp/, dondeInvariantGlobalizationestá activado,SqlConnection.OpenAsynclanzóSystem.NotSupportedException: Globalization Invariant Mode is not supported. - Una tabla cada vez, en una transacción cada una. Basta para 2962 filas. Una migración real también tiene que ocuparse de las escrituras durante la copia, que es para lo que sirve la replicación continua de DMS.
- La identidad. El
IDENT_CURRENTde SQL Server es 6, no 3: los dos inserts que fallaron por la restricción única, y la nota del procedimiento borrada después, usaron los valores 4 a 6 (salida del origen, líneas 62-67). El programa reinicianote_iden 7.
Qué pasa si nadie reinicia la identidad (sql/13-migration.sql, líneas 14-21):
-- La columna de identidad sigue empezando en 1: las filas copiadas no la hicieron avanzar. RESTART WITH continúa tras-- el IDENT_CURRENT de SQL Server, 6INSERT INTO migrated.notes (note_guid, author, body, written_at, written_local)VALUES (gen_random_uuid(), 'Reviewer', 'first note after the migration', '2026-09-16', '2026-09-16 00:00+00');ALTER TABLE migrated.notes ALTER COLUMN note_id RESTART WITH 7;INSERT INTO migrated.notes (note_guid, author, tag, body, written_at, written_local)VALUES ('70000000-0000-0000-0000-000000000000', 'Reviewer', 'migrated', 'first note after the migration', '2026-09-16', '2026-09-16 00:00+00')RETURNING note_id;INSERT 0 3ERROR: duplicate key value violates unique constraint "notes_pkey"DETAIL: Key (note_id)=(1) already exists.ALTER TABLE note_id--------- 7(1 row)
INSERT 0 1Validar la copia
Sección titulada «Validar la copia»Un recuento por tabla no es una validación. El programa lee cada fila de ambos servidores, en orden de clave, como texto en una forma en la que ambos coinciden, y después compara las filas y un SHA-256 de todas ellas (L13.cs, líneas 165-211):
// Un valor como texto: instantes en UTC con siete decimales, la precisión de datetime2 y datetimeoffset static string Text(object value) => value switch { DBNull => "NULL", DateTimeOffset o => o.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture), DateTime d => d.ToString("yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture), decimal m => m.ToString("0.0000", CultureInfo.InvariantCulture), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString()!, };
static async Task<List<string>> Rows(IDataReader reader, Func<Task<bool>> read) { var rows = new List<string>(); while (await read()) { var values = new object[reader.FieldCount]; reader.GetValues(values); rows.Add(string.Join('|', values.Select(Text))); } return rows; }
public static async Task Validate() { await using var source = new SqlConnection(SqlServer); await source.OpenAsync(); await using var target = Db.DataSource(); foreach (var check in Checks) { await using var sqlCommand = new SqlCommand(check.SqlServer, source); await using var sqlReader = await sqlCommand.ExecuteReaderAsync(); var expected = await Rows(sqlReader, () => sqlReader.ReadAsync()); await using var pgCommand = target.CreateCommand(check.PostgreSql); await using var pgReader = await pgCommand.ExecuteReaderAsync(); var actual = await Rows(pgReader, () => pgReader.ReadAsync());
var hash = (List<string> rows) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', rows))))[..12]; var differences = expected.Zip(actual).Where(pair => pair.First != pair.Second).ToList(); Console.WriteLine($"{check.Name}: {expected.Count} and {actual.Count} rows, SHA-256 {hash(expected)} and {hash(actual)}, {differences.Count} rows differ"); foreach (var (sql, pg) in differences) { Console.WriteLine($" SQL Server: {sql}"); Console.WriteLine($" PostgreSQL: {pg}"); } } }runs: 125 and 125 rows, SHA-256 D7C59BE754C6 and D7C59BE754C6, 0 rows differjobs: 315 and 315 rows, SHA-256 F0E778A31B09 and F0E778A31B09, 0 rows differjob_labels: 315 and 315 rows, SHA-256 13A05234F3CD and 13A05234F3CD, 0 rows differsteps: 2204 and 2204 rows, SHA-256 497D422AFAFA and 497D422AFAFA, 0 rows differnotes: 3 and 3 rows, SHA-256 B0585EF1C97F and 6A08288C6B5E, 2 rows differ SQL Server: 1|6f9619ff-8b86-d011-b42d-00c04fc964ff|34852867099|Reviewer|Slow|Deploy took 2.5 minutes|0.0125|2026-09-14 14:05:00.0000000|2026-09-14 14:05:00.1234567 PostgreSQL: 1|6f9619ff-8b86-d011-b42d-00c04fc964ff|34852867099|Reviewer|Slow|Deploy took 2.5 minutes|0.0125|2026-09-14 14:05:00.0000000|2026-09-14 14:05:00.1234560 SQL Server: 2|00000000-0000-0000-0000-000000000001|34852867099|reviewer |NULL|Looks fine now|NULL|2026-09-14 14:06:00.0070000|2026-09-14 14:06:00.9999999 PostgreSQL: 2|00000000-0000-0000-0000-000000000001|34852867099|reviewer |NULL|Looks fine now|NULL|2026-09-14 14:06:00.0070000|2026-09-14 14:06:00.9999990Cuatro tablas son idénticas. En notes, dos valores datetimeoffset(7) perdieron su séptimo decimal: .1234567 pasó a .123456 y .9999999 a .999999. Npgsql truncó los ticks de 100 nanosegundos a los microsegundos de PostgreSQL, que es lo que timestamptz puede contener. La solución es una decisión, no código: aceptar los microsegundos, o guardar los ticks en un bigint junto a la marca de tiempo.
Código en la base de datos
Sección titulada «Código en la base de datos»El origen también tiene una vista y un procedimiento (líneas 175-190):
-- Código que vive en la base de datos: una vista y un procedimiento, que también hay que convertirCREATE VIEW dbo.SlowJobs ASSELECT TOP (5) WITH TIES j.Name, r.WorkflowName, DATEDIFF(second, j.StartedAt, j.CompletedAt) AS SecondsFROM dbo.Jobs AS j JOIN dbo.Runs AS r ON r.RunId = j.RunIdORDER BY DATEDIFF(second, j.StartedAt, j.CompletedAt) DESC;GOCREATE PROCEDURE dbo.AddNote @RunId bigint, @Author nvarchar(50), @Body nvarchar(max), @Tag varchar(20), @NoteId int OUTPUTASBEGIN SET NOCOUNT ON; IF NOT EXISTS (SELECT 1 FROM dbo.Runs WHERE RunId = @RunId) THROW 50001, 'Unknown run', 1; INSERT INTO dbo.Notes (NoteGuid, RunId, Author, Tag, Body, WrittenAt, WrittenLocal) VALUES (NEWID(), @RunId, @Author, @Tag, @Body, GETUTCDATE(), SYSDATETIMEOFFSET()); SET @NoteId = SCOPE_IDENTITY();END;El procedimiento como función PL/pgSQL (lección 8), con RAISE en lugar de THROW y RETURNING … INTO en lugar de SCOPE_IDENTITY() (sql/13-migration.sql, líneas 73-90):
-- dbo.AddNote como función: RAISE en lugar de THROW, RETURNING en lugar de SCOPE_IDENTITY()CREATE FUNCTION migrated.add_note(p_run_id bigint, p_author varchar, p_body text, p_tag varchar DEFAULT NULL)RETURNS integerLANGUAGE plpgsqlAS $$DECLARE v_note_id integer;BEGIN IF NOT EXISTS (SELECT FROM migrated.runs WHERE run_id = p_run_id) THEN RAISE EXCEPTION 'Unknown run' USING ERRCODE = 'P0001'; END IF; INSERT INTO migrated.notes (note_guid, run_id, author, tag, body, written_at, written_local) VALUES (gen_random_uuid(), p_run_id, p_author, p_tag, p_body, now() AT TIME ZONE 'UTC', now()) RETURNING note_id INTO v_note_id; RETURN v_note_id;END$$;SELECT migrated.add_note(1, 'Reviewer', 'no such run');CREATE FUNCTIONERROR: Unknown runCONTEXT: PL/pgSQL function migrated.add_note(bigint,character varying,text,character varying) line 6 at RAISENEWID() pasa a ser gen_random_uuid(), GETUTCDATE() pasa a ser now() AT TIME ZONE 'UTC', y el parámetro OUTPUT pasa a ser el valor de retorno. La vista es el ejercicio 1.
pgloader
Sección titulada «pgloader»pgloader crea el esquema y copia los datos en un solo comando. check.sh ejecuta la imagen ghcr.io/dimitri/pgloader construida desde el commit 231ab86 (versión 3.6.10~devel), con sus reglas de conversión por defecto, en una base de datos propia:
docker run --rm --add-host=host.docker.internal:host-gateway ghcr.io/dimitri/pgloader@sha256:a1d4a78e78a64e46cd3fc7dfc57d24eb91ffb1a5520f2b1f55631815e3658d6e pgloader \ 'mssql://sa:Learn-2026!@host.docker.internal:1433/ci' \ 'postgresql://postgres:learn@host.docker.internal:5432/pgloader'Informó de 2962 filas y ningún error, en menos de un segundo. Lo que creó (sql/pgloader-check.sql, expected/13-pgloader.txt):
table_name | columns------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- joblabels | jobid bigint, label text jobs | jobid bigint, runid bigint, name text, conclusion text, runnername text, startedat timestamp with time zone, completedat timestamp with time zone, rowver bytea notes | noteid integer default nextval('dbo.notes_noteid_seq'::regclass), noteguid uuid, runid bigint, author text, tag text, body text, cost numeric, writtenat timestamp with time zone, writtenlocal timestamp with time zone runs | runid bigint, workflowname text, event text, conclusion text, headbranch text, headsha text, attempt smallint, createdat timestamp with time zone, startedat timestamp with time zone, updatedat timestamp with time zone, isrerun boolean steps | jobid bigint, number smallint, name text, conclusion text, startedat timestamp with time zone, completedat timestamp with time zone(5 rows)
noteid | author | cost | writtenat | writtenlocal--------+-----------+------+----------------------------+------------------------------- 1 | Reviewer | 0.01 | 2026-09-14 14:05:00+00 | 2026-09-14 14:05:00.123457+00 2 | reviewer | | 2026-09-14 14:06:00.007+00 | 2026-09-14 14:06:01+00 3 | REVIEWER | 3.50 | 2026-09-14 14:07:00.997+00 | 2026-09-14 14:07:00+00(3 rows)
last_value------------ 3(1 row)
contype | count---------+------- f | 4 n | 28 p | 5(3 rows)
index------------------------------------------------------------------------------ CREATE INDEX ix_jobs_runid ON dbo.jobs USING btree (runid, conclusion) CREATE UNIQUE INDEX pk_joblabels ON dbo.joblabels USING btree (jobid, label) CREATE UNIQUE INDEX pk__jobs ON dbo.jobs USING btree (jobid) CREATE UNIQUE INDEX pk__notes ON dbo.notes USING btree (noteid) CREATE UNIQUE INDEX pk__runs ON dbo.runs USING btree (runid) CREATE UNIQUE INDEX pk__steps ON dbo.steps USING btree (jobid, number) CREATE UNIQUE INDEX uq__notes ON dbo.notes USING btree (noteguid) CREATE UNIQUE INDEX uq_notes_tag ON dbo.notes USING btree (tag)(8 rows)moneyperdió dos decimales.0.0125pasó a0.01. pgloader lee las columnasdecimal,numeric,moneyysmallmoneycomoconvert(varchar(40), [col], 0)(mssql-schema.lisp, líneas 212-217), y paramoney, el estilo 0 significa «two digits to the right of the decimal point» (CASTyCONVERT).SELECT CONVERT(varchar(40), CAST(0.0125 AS money), 0)devuelve0.01en este SQL Server; el estilo 2 conserva cuatro dígitos. Ni error ni aviso: solo una validación lo detecta.datetimeoffsetse redondeó, no se truncó..1234567pasó a.123457, y10:06:00.9999999 -04:00pasó a14:06:01, el segundo siguiente. El programa C# truncó. Las dos copias no coinciden entre sí, ni ninguna de ellas con SQL Server.- Nombres y tipos.
RunIdpasó arunid, cada cadena atextsin su longitud,datetimeatimestamptz,rowversionabytea, y la columna calculada a un simplebooleancon los valores copiados. - Restricciones. Las claves primarias, las claves foráneas y
NOT NULLllegaron; elCHECK (Attempt >= 1)no, y no hay ningunacentre los tipos de restricción. Las restricciones únicas pasaron a ser índices únicos, sinNULLS NOT DISTINCTni collation que ignore las mayúsculas.IX_Jobs_RunId … INCLUDE (Conclusion)pasó a ser un índice sobre(runid, conclusion), con la columna incluida como clave. - La secuencia quedó en 3, la clave más grande, no en el 6 de SQL Server: aquí no hay conflicto, pero tampoco se respeta la numeración del origen.
pgloader es rápido para echar un primer vistazo a una base de datos. Sus reglas de conversión se pueden cambiar en un archivo de carga, y su resultado sigue necesitando la revisión y la validación de las secciones anteriores.
AWS DMS
Sección titulada «AWS DMS»Por verificar: nada de esta sección se ejecutó en AWS. AWS Database Migration Service copia datos entre bases de datos, desde una instancia de replicación o con DMS Serverless, como carga completa, como replicación continua, o ambas.
- Orígenes. «Microsoft SQL Server versions 2008 (supported in DMS v3.5.4), 2008R2(supported in DMS v3.5.4), 2012, 2014, 2016, 2017, 2019, and 2022» (orígenes). SQL Server 2025, la versión de esta lección, no figura. La replicación continua necesita la edición Enterprise, Developer o Standard (2016 y posteriores).
- Destinos. «AWS DMS only supports PostgreSQL version 17.x and 18.x in versions 3.5.4» (destinos).
- Replicación continua desde SQL Server. «The recovery model must be set to Bulk logged or Full», y «you must perform a backup before beginning to replicate data» (origen SQL Server). En un servidor autogestionado, DMS lee MS-Replication para las tablas con clave primaria y MS-CDC para las demás; «Amazon RDS for SQL Server doesn’t support MS-Replication», así que allí usa MS-CDC para todas las tablas (CDC).
- Lo que no traslada. «The identity property for a column isn’t migrated to a target database column», «Changes to computed fields in a SQL Server aren’t replicated», «AWS DMS doesn’t capture truncate commands» (limitaciones). DMS «doesn’t automatically create secondary indexes, foreign keys, user accounts, and so on» (prácticas recomendadas), y a las secuencias hay que fijarles el siguiente valor «after you stop the replication from the source database» (destino PostgreSQL): el reinicio de identidad de esta lección.
- Crea tú mismo las tablas de destino. Cuando las crea DMS, las tablas de tipos del origen y del destino, combinadas, dan a
datetime2con escala 7 una columnaVARCHAR (37), y adatetimeoffsetyuniqueidentifierunVARCHAR. Esa combinación de las dos tablas es mi lectura, no una afirmación de AWS. - Valores grandes. El modo LOB limitado, el predeterminado, «migrates all LOB values up to a user-specified size limit (default is 32 KB)» (LOB): un
nvarchar(max)más largo necesita el modo LOB completo o en línea. - Validación. «AWS DMS compares each row in the source with its corresponding row at the target … and reports any mismatches», y «requires that the table has a primary key or unique index» (validación de datos). Si informaría de los ticks perdidos de
datetimeoffset(7)está por verificar. - Esquema. DMS Schema Conversion «builds on the AWS Schema Conversion Tool (AWS SCT) conversion engine» y, de SQL Server a Aurora PostgreSQL, «can use generative AI to convert more»; AWS añade que «may not achieve 100 percent accuracy … You must review and validate all conversion outputs» (limitaciones). La herramienta de escritorio AWS SCT sigue publicándose (build 677).
- DMS Serverless. «The current engine version for AWS DMS Serverless is 3.5.4» (Serverless); «does not support views» (limitaciones).
El tutorial paso a paso de AWS recorre una conversión de SQL Server a Aurora PostgreSQL en la consola; el manual de migración compara funcionalidades, para SQL Server 2019.
Babelfish
Sección titulada «Babelfish»Por verificar. Babelfish toma el otro camino: la aplicación conserva su T-SQL y su driver de SQL Server, y Aurora PostgreSQL le responde. «SQL Server dialect (T-SQL), clients connect to port 1433. PostgreSQL dialect (PL/pgSQL), clients connect to port 5432» (Babelfish).
- Versiones. Babelfish 6.1.0 «is provided with Aurora PostgreSQL 18.4», publicada el 21 de agosto de 2026 (actualizaciones de Babelfish).
- Bases de datos. Aurora crea una base de datos PostgreSQL llamada
babelfish_db. En el modo de varias bases de datos, «the schema names of user databases become dbname_schemaname when accessed from PostgreSQL» (arquitectura). El modo se elige una sola vez: «You must not change this parameter after creating your cluster as you could lose access to all your previously created SQL objects»; la tabla de parámetros dice «you can’t modify this parameter’s value», con multi-db por defecto desde Aurora PostgreSQL 16. - Collations: dos valores por defecto. La tabla de parámetros da a
babelfishpg_tsql.server_collation_nameel valor por defectobbf_unicode_general_ci_as; la página de collations dice «The default value is sql_latin1_general_cp1_ci_as». La tabla comparativa de la misma página dice que PostgreSQL «Doesn’t support the LIKE clause on nondeterministic collations», algo que PostgreSQL 18 cambió. - Las funcionalidades no admitidas responden con un error o se ignoran, según los escape hatches; la lista de funcionalidades no admitidas incluye las rutinas CLR y los cursores actualizables. Babelfish Compass lee el DDL de una base de datos e informa de lo que Babelfish admite; su última versión es v.2026-07.
- No en local. El Babelfish for PostgreSQL de código abierto necesita un PostgreSQL modificado compilado desde el código fuente; su última versión es 5.4.0 para PostgreSQL 17.7, y el proyecto no publica ninguna imagen de contenedor. El curso no lo compila.
Babelfish traslada la base de datos sin reescribir la aplicación, y la aplicación conserva los comportamientos de SQL Server de las secciones anteriores, siempre que Babelfish los emule: cada uno está por verificar frente a las propias listas de Babelfish.
Por verificar: los precios cambian, y el curso no compró nada. Las cifras son los precios bajo demanda de AWS en US East (N. Virginia), de los archivos de la AWS Price List publicados el 2026-09-11, que también muestra la página de precios de Aurora.
| Aurora PostgreSQL | Aurora Standard | Aurora I/O-Optimized |
|---|---|---|
| db.r8g.large o db.r7g.large (2 vCPU, 16 GiB), por hora | 0,276 USD | 0,359 USD |
| almacenamiento, por GB-mes | 0,10 USD | 0,225 USD |
| E/S, por millón de solicitudes | 0,20 USD | incluida |
| Aurora Serverless, por ACU-hora | 0,12 USD | 0,16 USD |
| almacenamiento de copias de seguridad más allá del tamaño del clúster, por GB-mes | 0,021 USD | 0,021 USD |
Para comparar, de los mismos archivos:
- RDS for SQL Server Standard Edition, con licencia incluida, en una db.r6i.large Single-AZ: 1,02 USD por hora, unos 745 USD al mes por una instancia.
- La licencia en sí: la hoja de precios de SQL Server 2025 de Microsoft indica 3945 USD para Standard y 15 123 USD para Enterprise por paquete de 2 núcleos.
- La migración: una instancia de replicación dms.r6i.large cuesta 0,176 USD por hora; DMS Serverless, 0,0819 USD por DCU-hora, donde «One DCU equals 2GB of RAM» (precios de DMS). Babelfish «doesn’t have an additional cost» (DMS y Babelfish).
La guía prescriptiva de AWS afirma que pasar de SQL Server Enterprise en EC2 a Aurora «can result in cost savings up to 70 percent» (optimización de costes): una afirmación que conviene comprobar con tus propios tamaños de instancia, tu E/S y tus condiciones de licencia, en la AWS Pricing Calculator. El ejercicio 3 hace las cuentas para un clúster.
Puntos clave
Sección titulada «Puntos clave»- Ejecuta ambos servidores y hazles las mismas preguntas antes de trasladar los datos: collation, espacios finales, orden de
NULL, orden de los GUID,NULLen las restricciones únicas, precisión de las fechas, errores en las transacciones. - Una collation ICU no determinista da las comparaciones sin distinción de mayúsculas de SQL Server, incluido
LIKEdesde PostgreSQL 18; los espacios finales siguen siendo significativos. datetime2(7)ydatetimeoffsetpierden su séptimo decimal, ydatetimeoffsetsu desfase;datetimeya estaba redondeado en SQL Server.- Crea tú mismo el esquema de destino, carga, y después añade las claves foráneas y los índices, reinicia las identidades desde el
IDENT_CURRENTde SQL Server, y ejecutaANALYZE. - Valida filas, no recuentos: el programa encontró los ticks perdidos, y una validación habría encontrado los valores
moneyde pgloader cortados a dos decimales. - PostgreSQL aborta la transacción entera tras un error; SQL Server, por defecto, no.
- DMS copia y replica datos, pero no identidades, índices secundarios ni código; Babelfish conserva el T-SQL, y su comportamiento hay que comprobarlo frente a sus listas de compatibilidad.
- El precio de Aurora tiene tres partes: instancias, almacenamiento y E/S; por encima de cierto volumen de E/S, I/O-Optimized cuesta menos.
Ejercicios
Sección titulada «Ejercicios»- Convierte
dbo.SlowJobsen una vista PostgreSQL sobreci.jobsyci.runsde la lección 2, y comprueba que devuelve las mismas cinco filas que SQL Server.
Solución
-- Ejercicio 1: dbo.SlowJobs como vista PostgreSQL. DATEDIFF(second, ...) cuenta los límites de segundo cruzados;-- la diferencia de dos timestamptz truncados al segundo cuenta lo mismo.CREATE VIEW ci.slow_jobs ASSELECT j.name, r.workflow_name, extract(epoch FROM date_trunc('second', j.completed_at) - date_trunc('second', j.started_at))::integer AS secondsFROM ci.jobs AS j JOIN ci.runs AS r ON r.run_id = j.run_idORDER BY seconds DESCFETCH FIRST 5 ROWS WITH TIES;
SELECT * FROM ci.slow_jobs ORDER BY seconds DESC, name, workflow_name;CREATE VIEW name | workflow_name | seconds----------------------------------+----------------------+--------- java-for-csharp (windows-latest) | Java course examples | 284 java-for-csharp (macos-latest) | Java course examples | 270 java-for-csharp (windows-latest) | Java course examples | 270 java-for-csharp (windows-latest) | Java course examples | 255 java-for-csharp (windows-latest) | Java course examples | 254Las mismas filas que las de SQL Server. DATEDIFF cuenta los límites cruzados, así que de 10:00:00.9 a 10:00:01.1 hay un segundo; date_trunc('second', …) en ambos extremos cuenta de la misma forma, donde extract(epoch FROM completed_at - started_at) daría 0,2. TOP (5) WITH TIES pasa a ser FETCH FIRST 5 ROWS WITH TIES, y aquí se conserva el ORDER BY de la vista, pero la consulta que lee la vista sigue ordenando su propio resultado.
- Antes de convertir una base de datos SQL Server más grande, enumera las columnas cuyos tipos requieren una decisión, a partir del catálogo de SQL Server, y los autores que solo difieren en mayúsculas o en espacios finales.
Solución
-- Lección 13, ejercicio 2: las columnas cuyo tipo requiere una decisión antes de la conversión, según el catálogo de SQL ServerSET NOCOUNT ON;USE ci;SELECT t.name AS [table], c.name AS [column], ty.name AS type, c.scale, c.is_computed, c.is_identityFROM sys.columns AS cJOIN sys.tables AS t ON t.object_id = c.object_idJOIN sys.types AS ty ON ty.user_type_id = c.user_type_idWHERE ty.name IN ('datetime', 'smalldatetime', 'datetime2', 'datetimeoffset', 'money', 'smallmoney', 'tinyint', 'uniqueidentifier', 'timestamp', 'sql_variant', 'hierarchyid', 'xml', 'image', 'text', 'ntext') OR c.is_computed = 1 OR c.is_identity = 1ORDER BY t.name, c.column_id;
-- Collations distintas de la de la base de datos, y las cadenas que solo difieren en mayúsculas o espacios finalesSELECT t.name AS [table], c.name AS [column], c.collation_nameFROM sys.columns AS c JOIN sys.tables AS t ON t.object_id = c.object_idWHERE c.collation_name IS NOT NULL AND c.collation_name <> CAST(DATABASEPROPERTYEX('ci', 'Collation') AS sysname)ORDER BY t.name, c.column_id;SELECT Author COLLATE Latin1_General_BIN2 AS author, COUNT(*) AS notesFROM dbo.NotesGROUP BY Author COLLATE Latin1_General_BIN2ORDER BY author;GOChanged database context to 'ci'.table|column|type|scale|is_computed|is_identity-----|------|----|-----|-----------|-----------Jobs|StartedAt|datetime2|7|0|0Jobs|CompletedAt|datetime2|7|0|0Jobs|RowVer|timestamp|0|0|0Notes|NoteId|int|0|0|1Notes|NoteGuid|uniqueidentifier|0|0|0Notes|Cost|money|4|0|0Notes|WrittenAt|datetime|3|0|0Notes|WrittenLocal|datetimeoffset|7|0|0Runs|Attempt|tinyint|0|0|0Runs|CreatedAt|datetime2|0|0|0Runs|StartedAt|datetime|3|0|0Runs|UpdatedAt|datetimeoffset|7|0|0Runs|IsRerun|bit|0|1|0Steps|StartedAt|datetime2|0|0|0Steps|CompletedAt|datetime2|0|0|0table|column|collation_name-----|------|--------------author|notes------|-----REVIEWER|1Reviewer|1reviewer |1rowversion aparece como timestamp, su antiguo nombre, y la escala de datetime aparece como 3 aunque sus valores se redondean a 1/300 de segundo. Aquí ninguna columna tiene su propia collation. Una collation binaria, Latin1_General_BIN2, hace que el propio SQL Server muestre las tres grafías que su collation por defecto trata como una sola.
- Para un writer y una Aurora Replica en db.r8g.large, en funcionamiento todo el mes (730 horas), con 100 GiB de datos y 300 millones de solicitudes de E/S al mes, compara Aurora Standard con Aurora I/O-Optimized. ¿A partir de cuántas solicitudes de E/S al mes cuesta menos I/O-Optimized?
Solución
-- Ejercicio 3: un mes de 730 horas para un writer y una Aurora Replica, ambos db.r8g.large, con 100 GiB de datos y-- 300 millones de solicitudes de E/S, a los precios bajo demanda de us-east-1 de la AWS Price List publicada el 2026-09-11WITH price (configuration, instance_hour, gib_month, million_ios) AS ( VALUES ('Aurora Standard', 0.276, 0.10, 0.20), ('Aurora I/O-Optimized', 0.359, 0.225, 0.00))SELECT configuration, 2 * 730 * instance_hour AS instances, 100 * gib_month AS storage, 300 * million_ios AS io, 2 * 730 * instance_hour + 100 * gib_month + 300 * million_ios AS monthFROM priceORDER BY month;
-- El número de millones de solicitudes de E/S al mes a partir del cual I/O-Optimized cuesta menos, con las mismas instancias y datosSELECT round(((2 * 730 * 0.359 + 100 * 0.225) - (2 * 730 * 0.276 + 100 * 0.10)) / 0.20) AS million_ios; configuration | instances | storage | io | month----------------------+-----------+---------+-------+--------- Aurora Standard | 402.960 | 10.00 | 60.00 | 472.960 Aurora I/O-Optimized | 524.140 | 22.500 | 0.00 | 546.640(2 rows)
million_ios------------- 668Unos 473 USD frente a 547 USD: con 300 millones de solicitudes de E/S, Standard es más barato. Sin E/S, I/O-Optimized cuesta 133,68 USD más al mes en instancias y almacenamiento; a 0,20 USD por millón de solicitudes, la E/S de Standard alcanza esa cantidad hacia los 668 millones de solicitudes al mes. La regla práctica de AWS es considerar I/O-Optimized cuando la E/S supera el 25 % de la factura de Aurora (precios de Aurora). Quedan fuera las copias de seguridad, la transferencia de datos y las instantáneas.
Fuentes
Sección titulada «Fuentes»- SQL Server, en Microsoft Learn: novedades de SQL Server 2025, inicio rápido de Docker,
sqlcmd, collations,datetime,uniqueidentifier, comparar GUID, comparación de cadenas,ORDER BY,SET XACT_ABORT,CASTyCONVERT,DATEDIFF,OPENROWSET(BULK),OPENJSON, Microsoft.Data.SqlClient, Data Migration Assistant; hoja de precios de SQL Server 2025 - PostgreSQL 18: collations, notas de versión, coincidencia de patrones,
citext,CREATE TABLE, columnas generadas, tipos de fecha/hora,SAVEPOINT, errores y mensajes de PL/pgSQL, tipos monetarios, códigos de error - Npgsql: tipos de fecha y hora, tokens de concurrencia de EF Core
- pgloader: origen MS SQL,
mssql-schema.lispen231ab86 - AWS DMS, consultado el 2026-09-16: qué es DMS, orígenes, destinos, origen SQL Server, su CDC, destino PostgreSQL, prácticas recomendadas, validación de datos, Schema Conversion, sus limitaciones, AWS SCT, DMS Serverless y sus limitaciones, tutorial paso a paso, manual de migración
- Babelfish, consultado el 2026-09-16: descripción general, arquitectura, parámetros, collations, escape hatches, funcionalidades no admitidas, notas de versión, Babelfish Compass, Babelfish for PostgreSQL
- Precios, consultados el 2026-09-16: índice de la AWS Price List, precios de Aurora, precios de DMS, precios de RDS for SQL Server, AWS Pricing Calculator, guía prescriptiva sobre los costes de SQL Server