Introduction

Content migrations often change URLs.

The content itself may migrate correctly, but old URLs can still exist in Google, backlinks, documentation, emails, bookmarks, or other websites. Returning a 404 Not Found for those addresses throws away traffic and link equity that could instead be redirected to the migrated content.

Umbraco already has a mechanism for this. When a content URL changes, historical addresses can be stored in the umbracoRedirectUrl table and resolved to the current URL of the destination content.

Normally, Umbraco manages these records for you. During a migration, however, you may already know the old URLs and need to import them yourself.

I recently needed exactly that:

/disabling-resharper-in-visual-studio/

had moved to:

/blog/disabling-resharper-in-visual-studio/

I wanted the original address to return a real HTTP 301 Moved Permanently.

Here is how I implemented and tested it on Umbraco 17.

Important

Directly modifying Umbraco database tables bypasses Umbraco's service layer. Test this against a restored local copy of your database first, take a backup before production changes, and revalidate the implementation when changing Umbraco versions.

The game plan in 13 seconds

Understanding umbracoRedirectUrl

In the Umbraco 17 database, redirect records are stored in:

dbo.umbracoRedirectUrl

The relevant columns are:

Column

Purpose

id

Unique identifier of the redirect record

contentKey

GUID of the destination content item

createDateUtc

Creation timestamp

url

Historical URL that should redirect

culture

Content culture for a variant

urlHash

SHA-1 hash used to identify the historical URL

The important concept is that the table does not need the new URL.

Instead, it contains:

old URL -> contentKey

Umbraco can resolve that content item to its current published URL.

That is useful because another future URL change does not require you to rewrite every historical redirect to contain the latest destination URL.

How urlHash is generated

A real record from the database gave me this pair:

/configuring-umbraco-media-on-azure-private-blob-storage

3cb3b3ffbb58bf125cca803211ac35b2fd7b3d78

Calculating SHA-1 for that URL produces exactly the same value:

SHA1("/configuring-umbraco-media-on-azure-private-blob-storage")
=
3cb3b3ffbb58bf125cca803211ac35b2fd7b3d78

Another existing record confirmed the result:

/blog/how-to-enhance-umbraco-media-folder-management

1632b49cf8704367be208aeba4a6efdf5603808a

This means SQL Server can calculate the value when inserting the redirect:

LOWER(CONVERT(VARCHAR(40),
    HASHBYTES('SHA1', CONVERT(VARCHAR(MAX), @Url)),
    2
))

There is no reason to manually calculate and paste hashes into migration scripts.

Culture matters

On a multilingual Umbraco installation, culture can contain values such as:

en
pl-pl

while non-variant records may contain:

NULL

Do not assume that every installation uses values such as en-US.

Instead, check the actual Umbraco configuration:

SELECT
    id,
    languageISOCode,
    languageCultureName,
    isDefaultVariantLang,
    mandatory,
    fallbackLanguageId
FROM dbo.umbracoLanguage;

The stored procedure can then validate the supplied culture against languageISOCode.

Finding the destination content key

Umbraco 17 Info tab showing Redirect URL Management and the content item GUID

Umbraco 17 Info tab showing Redirect URL Management and the content item GUID

You need the GUID of the destination content item, not the numeric node ID.

There is an easy way to find it without querying SQL Server.

Open the destination document in the Umbraco backoffice and select:

Content → your document → Info

Look for:

Id

For example:

8c64bd38-7045-436c-a176-d753981ebb50

That GUID is the value to use as contentKey.

This is particularly important after a content migration. Do not assume that a GUID from an old installation or old redirect record is still the identifier of the newly migrated document.

Creating a reusable stored procedure

For my migration scripts I used a small stored procedure instead of writing raw INSERT statements repeatedly.

CREATE OR ALTER PROCEDURE dbo.pbRedirectUrlAdd
    @ContentKey UNIQUEIDENTIFIER,
    @Url NVARCHAR(2048),
    @Culture NVARCHAR(20) = NULL
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    SET @Url = LTRIM(RTRIM(@Url));

    IF @Url = ''
        THROW 50001, 'Url cannot be empty.', 1;

    IF NOT EXISTS (
        SELECT 1
        FROM dbo.umbracoNode
        WHERE uniqueId = @ContentKey
    )
        THROW 50002, 'ContentKey does not exist.', 1;

    IF @Culture IS NOT NULL AND NOT EXISTS (
        SELECT 1
        FROM dbo.umbracoLanguage
        WHERE languageISOCode = @Culture
    )
        THROW 50003, 'Culture does not exist.', 1;

    DECLARE @UrlHash VARCHAR(40);

    SET @UrlHash = LOWER(CONVERT(VARCHAR(40),
        HASHBYTES('SHA1', CONVERT(VARCHAR(MAX), @Url)),
        2
    ));

    IF EXISTS (
        SELECT 1
        FROM dbo.umbracoRedirectUrl
        WHERE urlHash = @UrlHash
          AND (culture = @Culture OR (culture IS NULL AND @Culture IS NULL))
    )
        THROW 50004, 'Redirect already exists.', 1;

    INSERT INTO dbo.umbracoRedirectUrl (
        id,
        contentKey,
        createDateUtc,
        url,
        culture,
        urlHash
    )
    VALUES (
        NEWID(),
        @ContentKey,
        GETUTCDATE(),
        @Url,
        @Culture,
        @UrlHash
    );
END;
GO

There are a few deliberate checks here.

First, the destination contentKey must exist in umbracoNode. In Umbraco 17 the relevant column is uniqueId.

Second, when a culture is supplied, it must exist in umbracoLanguage.

Finally, the procedure prevents another redirect with the same URL hash and culture from being inserted.

Adding a redirect

Suppose this URL:

/disabling-resharper-in-visual-studio/

should redirect to the current article:

/blog/disabling-resharper-in-visual-studio/

After finding the GUID of the destination article in the Info tab, execute:

EXEC dbo.pbRedirectUrlAdd
    @ContentKey = '8c64bd38-7045-436c-a176-d753981ebb50',
    @Url = '/disabling-resharper-in-visual-studio',
    @Culture = 'en';

Notice that I do not pass:

/blog/disabling-resharper-in-visual-studio/

to the procedure.

The destination is represented by @ContentKey. The URL parameter is the old URL being registered.

The same approach can be used for another migrated article:

EXEC dbo.pbRedirectUrlAdd
    @ContentKey = 'YOUR-DESTINATION-CONTENT-GUID',
    @Url = '/best-umbraco-cms-packages',
    @Culture = 'en';

where the destination document currently lives at:

/blog/best-packages-for-umbraco-free-and-paid/

Verify the database records

After running the migration, check what was inserted:

SELECT
    id,
    contentKey,
    createDateUtc,
    url,
    culture,
    urlHash
FROM dbo.umbracoRedirectUrl
WHERE url IN (
    '/disabling-resharper-in-visual-studio',
    '/best-umbraco-cms-packages'
);

This verifies the database operation, but it does not prove that the website returns the expected redirect.

There are two more useful checks.

Check Redirect URL Management in Umbraco

Open the destination document and go back to the Info tab.

Under Redirect URL Management, Umbraco should list the historical URL that redirects to the document.

For example:

en    /disabling-resharper-in-visual-studio/

This is a convenient way to verify that Umbraco recognizes the database record and associates it with the expected content item.

Verify the actual HTTP 301

Chrome DevTools Network panel showing a 301 Moved Permanently response for an old Umbraco URL

Chrome DevTools Network panel showing a 301 Moved Permanently response for an old Umbraco URL

This is the final test, and I would not skip it.

The browser ending up on the correct page does not by itself prove that the server returned the status code you wanted.

Open Chrome DevTools and:

  1. Select Network.

  2. Enable Preserve log if necessary.

  3. Enable Disable cache while DevTools is open.

  4. Navigate directly to the old URL.

  5. Select the request for the old document.

  6. Open Headers.

  7. Check Status Code.

You want to see:

301 Moved Permanently
GET /disabling-resharper-in-visual-studio/
        |
301 Moved Permanently
        |
/blog/disabling-resharper-in-visual-studio/
        |
200 OK

That verifies the whole mechanism rather than just the database state.

Why not just insert records manually?

You can.

A plain INSERT works if you construct every required value correctly. The stored procedure is useful because migrations tend to involve more than one URL.

It centralizes:

  • content validation,

  • culture validation,

  • hash generation,

  • duplicate detection,

  • record creation.

It also makes a migration script considerably easier to review:

EXEC dbo.pbRedirectUrlAdd
    @ContentKey = '...',
    @Url = '/old-url',
    @Culture = 'en';

rather than repeating knowledge about Umbraco's table structure for every redirect.

Should you use this in application code?

I would distinguish a controlled migration from normal application development.

For a one-off migration where I have a database backup, a known Umbraco version, a reviewed list of URLs, and the ability to test everything before production, a SQL migration can be pragmatic.

For normal application functionality, I would prefer Umbraco's APIs rather than making application code depend directly on internal database tables.

Database schemas and internal implementation details can change between Umbraco versions.

Treat this procedure as a version-specific migration tool, not as a new public API for your application.

Migration checklist

Before production:

  • back up the database,

  • test against a restored local database,

  • confirm the destination content GUID,

  • verify languageISOCode,

  • insert the redirect,

  • check Info → Redirect URL Management,

  • request the historical URL,

  • verify 301 Moved Permanently in DevTools,

  • verify the final destination,

  • test representative URLs containing non-ASCII characters separately if your migration contains them.

A successful SQL statement is only the first half of the test.

The HTTP response is the final proof.

FAQ

Where does Umbraco 13 store historical redirect URLs?

They are stored in the umbracoRedirectUrl table. Each record associates a historical URL with the GUID of the destination content item.

What is contentKey?

For this table, it identifies the destination Umbraco content item. In the Umbraco 13 database it can be matched against umbracoNode.uniqueId.

How can I find the content GUID without SQL?

Open the document in the backoffice and select Info → Id.

What is urlHash?

In the records examined here, it is a lowercase hexadecimal SHA-1 hash of the historical URL. The examples were independently reproduced with SHA-1 during preparation of this article.

Should the new URL be stored in umbracoRedirectUrl?

No. The redirect associates the historical URL with a content key. Umbraco then resolves the current URL of that content.

How do I know the redirect is really permanent?

Use the browser's Network tools and inspect the response for the historical URL. It should return:

301 Moved Permanently

Can I execute this directly in production?

Technically yes, but that should not be the first test. Restore a backup locally, execute the migration there, verify Umbraco's backoffice and HTTP behavior, back up production, and only then consider the production migration.