Rediger

Migrate Django apps from PostgreSQL to SQL Server

This article is a detailed migration guide for Django applications moving from PostgreSQL (psycopg2 or psycopg) to SQL Server (mssql-django). For a general overview of migrating from any database, see Migrate Django apps from other databases to SQL Server.

Prerequisites

  • Python 3.8 or later
  • Microsoft ODBC Driver 17 or 18 for SQL Server. See Install mssql-django.
  • SQL Server 2016 or later, or Azure SQL Database

Switch the database backend

Replace your PostgreSQL configuration in settings.py:

# Before (PostgreSQL)
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "mydb",
        "USER": "myuser",
        "PASSWORD": "mypassword",
        "HOST": "localhost",
        "PORT": "5432",
    },
}

# After (SQL Server)
DATABASES = {
    "default": {
        "ENGINE": "mssql",
        "NAME": "mydb",
        "USER": "myuser",
        "PASSWORD": "mypassword",
        "HOST": "localhost",
        "PORT": "1433",
        "OPTIONS": {
            "driver": "ODBC Driver 18 for SQL Server",
        },
    },
}

Update requirements.txt:

# Remove
# psycopg2-binary>=2.9
# or psycopg[binary]>=3.1

# Add
mssql-django>=1.5

Replace django.contrib.postgres features

The django.contrib.postgres module provides PostgreSQL-specific fields, functions, and lookups. These don't work with SQL Server. The following sections show how to replace each feature.

ArrayField

PostgreSQL ArrayField stores arrays natively. SQL Server doesn't have an array column type.

Option 1: JSONField (works with Django 3.2 and later versions)

# Before
from django.contrib.postgres.fields import ArrayField

class Product(models.Model):
    tags = ArrayField(models.CharField(max_length=50), default=list)

# After
class Product(models.Model):
    tags = models.JSONField(default=list)

Querying changes:

# Before (PostgreSQL)
Product.objects.filter(tags__contains=["sale"])
Product.objects.filter(tags__overlap=["sale", "new"])
Product.objects.filter(tags__len=3)

# After (SQL Server with JSONField)
# Use __contains for exact list matching
Product.objects.filter(tags__contains=["sale"])

# For overlap-style queries, use raw SQL
from django.db.models.expressions import RawSQL
Product.objects.filter(
    pk__in=RawSQL(
        """
        SELECT p.id FROM products_product p
        CROSS APPLY OPENJSON(p.tags) t
        WHERE t.value IN (%s, %s)
        """,
        ["sale", "new"],
    )
)

Option 2: Related table (normalized, better for large arrays or frequent filtering)

class Product(models.Model):
    name = models.CharField(max_length=200)

class ProductTag(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="tags")
    tag = models.CharField(max_length=50, db_index=True)

    class Meta:
        unique_together = [("product", "tag")]

HStoreField

Replace with JSONField:

# Before
from django.contrib.postgres.fields import HStoreField

class Profile(models.Model):
    metadata = HStoreField(default=dict)

# After
class Profile(models.Model):
    metadata = models.JSONField(default=dict)

JSONField supports the same key lookup syntax:

# Both backends support this
Profile.objects.filter(metadata__theme="dark")

Range fields

PostgreSQL range types (IntegerRangeField, BigIntegerRangeField, DateRangeField, DateTimeRangeField, DecimalRangeField) have no SQL Server equivalent. Use two separate fields:

# Before
from django.contrib.postgres.fields import DateRangeField

class Event(models.Model):
    dates = DateRangeField()

# After
class Event(models.Model):
    start_date = models.DateField()
    end_date = models.DateField()

Update queries to use separate field comparisons. Before, with PostgreSQL's DateRangeField:

from django.contrib.postgres.fields import DateRangeField
from psycopg2.extras import DateRange

Event.objects.filter(dates__contains=DateRange(start, end))

After, with two DateField columns on SQL Server:

from datetime import date

start = date(2026, 1, 1)
end = date(2026, 12, 31)

Event.objects.filter(start_date__lte=start, end_date__gte=end)

CITextField and CIEmailField

PostgreSQL's case-insensitive text types use the citext extension. SQL Server's default collation (SQL_Latin1_General_CP1_CI_AS) is already case-insensitive, so standard CharField and EmailField behave the same way:

# Before
from django.contrib.postgres.fields import CITextField

class Tag(models.Model):
    name = CITextField(max_length=100)

# After - already case-insensitive with default SQL Server collation
class Tag(models.Model):
    name = models.CharField(max_length=100)

SearchVector, SearchQuery, SearchRank

PostgreSQL full-text search is deeply integrated with Django. SQL Server has its own full-text search engine but no Django ORM integration. See Full-text search migration later in this article.

Aggregate functions

Replace PostgreSQL-specific aggregates:

# Before
from django.contrib.postgres.aggregates import ArrayAgg, StringAgg

Product.objects.values("category").annotate(
    all_names=ArrayAgg("name"),
    name_list=StringAgg("name", delimiter=", "),
)

# After - use SQL Server equivalents via RawSQL
from django.db.models.expressions import RawSQL

Product.objects.values("category").annotate(
    name_list=RawSQL(
        "STRING_AGG(name, ', ') WITHIN GROUP (ORDER BY name)",
        [],
    ),
)

Note

STRING_AGG requires SQL Server 2017 or later or Azure SQL Database.

Full-text search migration

PostgreSQL full-text search uses tsvector, tsquery, and GIN indexes. SQL Server has a separate full-text search engine.

Enable full-text search in SQL Server

-- Create a full-text catalog
CREATE FULLTEXT CATALOG [MyAppCatalog] AS DEFAULT;

-- Create a full-text index (table must have a unique index)
CREATE FULLTEXT INDEX ON [products_product]([name], [description])
KEY INDEX [PK_products_product]
WITH CHANGE_TRACKING AUTO;

Query full-text search from Django

Use raw SQL to access SQL Server's CONTAINS and FREETEXT functions:

from django.db.models.expressions import RawSQL

# Equivalent of PostgreSQL SearchVector + SearchQuery
def search_products(query):
    return Product.objects.filter(
        pk__in=RawSQL(
            """
            SELECT p.id FROM products_product p
            WHERE CONTAINS((p.name, p.description), %s)
            """,
            [query],
        )
    )

For ranked results (equivalent to SearchRank):

def search_products_ranked(query):
    return Product.objects.raw(
        """
        SELECT p.*, ft.[RANK]
        FROM products_product p
        INNER JOIN CONTAINSTABLE(products_product, (name, description), %s) ft
            ON p.id = ft.[KEY]
        ORDER BY ft.[RANK] DESC
        """,
        [query],
    )

Full-text index maintenance runbook

Plan maintenance for SQL Server full-text indexes after migration:

  • Use CHANGE_TRACKING AUTO for near-real-time updates.
  • Use CHANGE_TRACKING MANUAL for bulk-load windows, then run a full population.
  • Track crawl status and backlog through sys.fulltext_indexes and sys.dm_fts_index_population.

Check status:

SELECT
    OBJECT_NAME(i.object_id) AS table_name,
    i.change_tracking_state_desc,
    i.has_crawl_completed,
    i.crawl_type_desc
FROM sys.fulltext_indexes AS i;

After large data loads with manual tracking:

ALTER FULLTEXT INDEX ON [products_product] START FULL POPULATION;

Tip

Rebuild or repopulate full-text indexes during low-traffic windows. Full populations can be expensive on large tables.

Create a search manager

Wrap the raw SQL in a manager for clean access:

class ProductSearchManager(models.Manager):
    def search(self, query):
        if not query:
            return self.none()
        return self.filter(
            pk__in=RawSQL(
                """
                SELECT p.id FROM products_product p
                WHERE CONTAINS((p.name, p.description), %s)
                """,
                [query],
            )
        )

class Product(models.Model):
    name = models.CharField(max_length=200)
    description = models.TextField()

    objects = ProductSearchManager()
    # Usage: Product.objects.search("mountain bike")

PostGIS and spatial data

mssql-django doesn't include a GeoDjango GIS backend. If your PostgreSQL application uses PostGIS through django.contrib.gis, you can't migrate spatial queries directly to the Django ORM on SQL Server.

SQL Server does support geography and geometry data types natively. To work with spatial data after migration:

  • Store spatial data using raw SQL or custom model fields that map to SQL Server's geography or geometry columns.
  • Query spatial data using raw SQL with SQL Server's built-in spatial functions:
from django.db import connection

with connection.cursor() as cursor:
    cursor.execute(
        """
        SELECT id, name
        FROM stores
        WHERE location.STDistance(geography::Point(%s, %s, 4326)) <= %s
        """,
        [latitude, longitude, radius_meters],
    )
  • Consider third-party libraries that add SQL Server spatial support to Django, or keep spatial queries as raw SQL while using the ORM for everything else.

Note

If your application heavily depends on GeoDjango spatial lookups, evaluate the migration cost carefully. Moving spatial queries to raw SQL requires rewriting each GeoDjango spatial filter.

Connection pooling migration

If your PostgreSQL application uses pgbouncer for connection pooling, replace it with Django's built-in connection management or ODBC connection pooling.

Django connection reuse

DATABASES = {
    "default": {
        "ENGINE": "mssql",
        "NAME": "<your-database>",
        "HOST": "<your-server>",
        "OPTIONS": {
            "driver": "ODBC Driver 18 for SQL Server",
        },
        "CONN_MAX_AGE": 600,  # Reuse connections for 10 minutes
        "CONN_HEALTH_CHECKS": True,  # Django 4.1+
    },
}

For more details, see Connection pooling in mssql-django.

DISTINCT ON replacement

PostgreSQL supports DISTINCT ON to get one row per group. SQL Server doesn't support this syntax. Use window functions instead:

# Before (PostgreSQL)
Entry.objects.order_by("blog_id", "-pub_date").distinct("blog_id")

# After (SQL Server) - use raw SQL with ROW_NUMBER
Entry.objects.raw(
    """
    SELECT * FROM (
        SELECT *, ROW_NUMBER() OVER (PARTITION BY blog_id ORDER BY pub_date DESC) AS rn
        FROM blog_entry
    ) sub
    WHERE rn = 1
    """
)

JSONB queries

PostgreSQL's jsonb type supports rich query operators. SQL Server stores JSON as nvarchar(max) with query functions available since SQL Server 2016.

Django's JSONField lookup syntax works on both backends for basic operations:

# Works on both PostgreSQL and SQL Server
Config.objects.filter(data__settings__theme="dark")
Config.objects.filter(data__has_key="settings")

For advanced JSON queries not supported by Django's ORM, use SQL Server's JSON_VALUE and OPENJSON functions:

from django.db.models.expressions import RawSQL

# Query nested JSON values
Config.objects.annotate(
    theme=RawSQL("JSON_VALUE(data, '$.settings.theme')", [])
).filter(theme="dark")

Remove PostgreSQL dependencies

After migration, remove PostgreSQL packages from your project:

pip uninstall psycopg2-binary psycopg2 psycopg

Remove django.contrib.postgres from INSTALLED_APPS in settings.py:

INSTALLED_APPS = [
    # Remove this line:
    # "django.contrib.postgres",
    "django.contrib.admin",
    "django.contrib.auth",
    # ...
]

Migration checklist

Step Details
Switch backend Replace django.db.backends.postgresql with mssql in settings.py.
Replace contrib.postgres Swap ArrayField, HStoreField, range fields, and CI fields.
Update full-text search Migrate from tsvector/tsquery to SQL Server CONTAINS/FREETEXT.
Update spatial queries Rewrite GeoDjango lookups as raw SQL using SQL Server spatial functions.
Replace DISTINCT ON Use ROW_NUMBER() window functions.
Update raw SQL Change PostgreSQL syntax (LIMIT, ||, NOW()) to SQL Server syntax. See Update custom SQL.
Enable RCSI Set READ_COMMITTED_SNAPSHOT ON to match PostgreSQL MVCC behavior. See Transaction isolation differences.
Test collation Verify case-sensitivity behavior matches your expectations. See Collation differences.
Remove psycopg2 Uninstall psycopg2-binary or psycopg. Remove django.contrib.postgres.
Regenerate migrations Delete old migration files, run makemigrations and migrate fresh.
Migrate data Use dumpdata/loaddata or an ETL tool for large datasets.