Admin adding "5F" to middle of object IDs in URLs

I recently migrated some models over to use a “table-prefixed ULID” datatype similar to Stripe’s ID’s (i.e. of the form tbl_01JCSG38GGZZEC764V1CMXNRT9). I did this by creating a custom Model Field and using that on the models. Everything is working great, except, somehow, the admin panel is taking an ID that should be vid_01JCSG38GGZZEC764V1CMXNRT9 and making it vid_5F01JCSG38GGZZEC764V1CMXNRT9.

To make things even more confusing, it’s only doing it in the URL. I have the ID field being shown on the admin change page and it’s the correct vid_01JCSG38GGZZEC764V1CMXNRT9. The 5F also is not actually important in the URL. I.e. http://127.0.0.1:8000/admin/videos/video/vid_5F01JCSG38GGZZEC764V1CMXNRT9/change/ and http://127.0.0.1:8000/admin/videos/video/vid_01JCSG38GGZZEC764V1CMXNRT9/change/ both take me to the correct page. It’s also always “5F” that it adds in the middle of the string and happens across all models I’ve applied this field to.

I’m quite certain there’s a bug in the field I’ve written, but for the life of me I can’t figure it out and have literally searched the entirety of my code base and Django’s and “5F” doesn’t appear anywhere. I’ve even put a breakpoint where the string is being returned from the custom field and it never has 5F there, but somehow the string ends up with it in it.

Anyone have any suggestions about where to look or further debugging steps to take?

Here’s the custom field I created:

from collections import defaultdict
from typing import List, Dict, Any

import ulid
from django import forms
from django.core import checks
from django.core import exceptions
from django.db import models


class TablePrefixUlidPkField(models.UUIDField):
    empty_strings_allowed = False
    used_table_prefixes_to_fields: Dict[
        str, List['TablePrefixUlidPkField']
    ] = defaultdict(list)

    def __init__(self, table_prefix, *args, **kwargs):
        if not table_prefix:
            raise exceptions.ValidationError("Table Prefix cannot be empty.")
        if not isinstance(table_prefix, str):
            raise exceptions.ValidationError("Table Prefix must be a string.")

        self.table_prefix = table_prefix
        self.used_table_prefixes_to_fields[self.table_prefix].append(self)

        kwargs['default'] = self.table_prefixed_ulid_generator(table_prefix)
        kwargs['primary_key'] = True
        kwargs['editable'] = False
        kwargs['blank'] = True
        kwargs['db_comment'] = (
            "This field is a ULID being stored as a UUID. When used in the API (and "
            "everywhere outside the DB really), the ULID is shown with a "
            "'table_prefix' prepended to it. When querying the DB directly you'll need "
            "to remove this prefix and confirm the ULID to UUIDs"
        )

        super().__init__(*args, **kwargs)

    @staticmethod
    def table_prefixed_ulid_generator(table_prefix: str):
        def new_ulid():
            return f"{table_prefix}_{ulid.new()}"

        return new_ulid

    def check(self, **kwargs):
        errors = super().check(**kwargs)

        if len(self.used_table_prefixes_to_fields[self.table_prefix]) > 1:
            errors.append(
                checks.Error(
                    f"Duplicate table prefix found: {self.table_prefix}",
                    hint="Change either table's prefix to not conflict.",
                    obj=self,
                    id="common.E001",
                )
            )

        return errors

    def get_db_prep_value(self, value, connection, prepared=False):
        _, ulid_obj = convert_value_to_known_forms(self.table_prefix, value)

        if ulid_obj is None:
            return None

        return super().get_db_prep_value(ulid_obj.uuid, connection, prepared)

    def from_db_value(self, value, expression, connection):
        return self.to_python(value)

    def to_python(self, value):
        string_value, _ = convert_value_to_known_forms(self.table_prefix, value)
        return string_value

    def formfield(self, **kwargs):
        defaults = {"form_class": table_prefix_form_field_factory(self.table_prefix)}
        defaults.update(kwargs)
        return super().formfield(**defaults)

    def deconstruct(self):
        name, path, args, kwargs = super().deconstruct()
        args.insert(0, self.table_prefix)

        # These fields are all forcibly set during __init__
        for field_name in (
                'default',
                'primary_key',
                'editable',
                'blank',
                'db_comment',
        ):
            if field_name in kwargs:
                del kwargs[field_name]
        return name, path, args, kwargs


def table_prefix_form_field_factory(outer_table_prefix):
    class TablePrefixUlidPkFormField(forms.CharField):
        table_prefix = outer_table_prefix

        def prepare_value(self, value):
            str_value, _ = convert_value_to_known_forms(self.table_prefix, value)
            return str_value

        def to_python(self, value):
            str_value, _ = convert_value_to_known_forms(self.table_prefix, value)
            return str_value

    return TablePrefixUlidPkFormField


def convert_value_to_known_forms(
        expected_table_prefix: str,
        input_value: Any
) -> tuple[str | None, ulid.ULID | None]:
    if input_value is None:
        return None, None

    string_version = str(input_value)
    split = string_version.split("_")
    table_prefix = "_".join(split[:-1])
    ulid_string = split[-1]
    if table_prefix and table_prefix != expected_table_prefix:
        raise exceptions.ValidationError(
            'Invalid DB ID. Table prefix is incorrect; this ID likely does '
            'not belong to this table.',
            code='invalid',
        )

    try:
        parsed_ulid = ulid.parse(ulid_string)
    except (AttributeError, ValueError) as e:
        raise exceptions.ValidationError('Invalid DB ID.', code='invalid') from e

    display_string = f"{expected_table_prefix}_{parsed_ulid}"
    return display_string, parsed_ulid

5F is the ASCII hex value of the underscore character. I’d suspect something is trying to URL-safe a value or otherwise encode it and is doing this.