Importing Django Models to PormG
This tutorial provides a guide on how to migrate Django models to PormG using the import_models_from_django function.
Overview
The import_models_from_django function allows you to seamlessly convert Django model definitions from Python models.py files into PormG-compatible Julia models. This is particularly useful when:
- Migrating existing Django projects to Julia
- Maintaining consistency between Django and Julia models
- Quickly prototyping Julia models based on existing Django schemas
- Converting legacy Django models for use in Julia applications
- Creating an ETL pipeline for data processing in Julia from an existing Django application
Function Signature
Two arities: one models.py, or a whole multi-app project.
# one app
import_models_from_django(
model_py_string::String;
db::String = DB_PATH,
force_replace::Bool = false,
file::String = "automatic_models.jl",
output_path::Union{Nothing, String} = nothing,
django_prefix::Union{Nothing, String, Missing} = missing,
auth_user_model::Union{Nothing, String} = nothing,
strict_relations::Bool = false,
strict_fields::Bool = false,
binding_overrides::AbstractDict = Dict{String, String}(),
autofields_ignore::Vector{String} = ["Manager"],
parameters_ignore::Vector{String} = ["help_text"]
)
# a project — "<app_label>" => "<models.py path or source>" pairs
import_models_from_django(
apps::AbstractVector{<:Pair};
db::String = DB_PATH,
force_replace::Bool = false,
file::String = "automatic_models.jl",
output_path::Union{Nothing, String} = nothing,
auth_user_model::Union{Nothing, String} = nothing,
strict_relations::Bool = false,
strict_fields::Bool = false,
binding_overrides::AbstractDict = Dict{String, String}(),
autofields_ignore::Vector{String} = ["Manager"],
parameters_ignore::Vector{String} = ["help_text"]
)See Importing a multi-app project for the second form.
Basic Usage
Step 1: Prepare Your Django Models File
Ensure your Django models.py file follows standard Django conventions:
# models.py
from django.db import models
from django.contrib.auth.models import AbstractUser
class Category(models.Model):
name = models.CharField(max_length=100, unique=True)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class User(AbstractUser):
email = models.EmailField(unique=True)
bio = models.TextField(max_length=500, blank=True)
birth_date = models.DateField(null=True, blank=True)
class Product(models.Model):
name = models.CharField(max_length=200)
category = models.ForeignKey(Category, on_delete=models.CASCADE)
price = models.DecimalField(max_digits=10, decimal_places=2)
is_active = models.BooleanField(default=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)Step 2: Import Using File Path
using PormG.Migrations
import_models_from_django("/path/to/your/models.py")
Parameters Explained
Required Parameters
model_py_string::String: The content of your Djangomodels.pyfile. Can be:- File path to your
models.pyfile - String content of the models file
- Output from
django_to_string(path)
- File path to your
Optional Parameters
db::String: Database configuration key (default:DB_PATH)import_models_from_django(content, db="production")force_replace::Bool: Overwrite existing model files (default:false)import_models_from_django(content, force_replace=true)file::String: Output filename (default:"automatic_models.jl")import_models_from_django(content, file="my_models.jl")autofields_ignore::Vector{String}: Field types to ignore, named the way themodels.pyspells them (default:["Manager"])import_models_from_django(content, autofields_ignore=["Manager", "CustomManager"])A dropped field still claims its column name. Dropping
id = models.CharField(max_length=10)does not hand the name back to Django's implicitid: substituting a BIGINT auto-increment key there would mis-type the one column every query reads, so the model comes out with no key and a# PormG:marker saying why. If every column of a class is dropped this way the import raises, naming the list you passed — a model with no column cannot be emitted.parameters_ignore::Vector{String}: Field parameters to ignore (default:["help_text"]). The list you pass replaces the default rather than adding to it, so repeat"help_text"if you still want it ignored:import_models_from_django(content, parameters_ignore=["help_text", "verbose_name"])output_path::Union{Nothing, String}: Directory to write the generated file into, overriding the resolved config'sdb_def_folder(default:nothing). Use it to stage a foreign Django app'smodels.pynext to its own copy while still resolvingdbfor the connection Settings.django_prefix::Union{Nothing, String, Missing}: The Django app label whose tables are being imported (default:missing).missinginherits the resolved config'sdjango_prefix;nothingimports unprefixed; aStringforces that label. See The app prefix below.# A foreign app staged in db_garage/, written to its own folder with its own label: import_models_from_django("db_garage/models.py"; db="db", file="garage_models.jl", output_path="db_garage", django_prefix="garage", force_replace=true)auth_user_model::Union{Nothing, String}: which modelsettings.AUTH_USER_MODELrefers to, spelled the way Django spells it insettings.py(default:nothing, which auto-detects the single class inheritingAbstractUser).import_models_from_django(pairs; auth_user_model="access.User")strict_relations::Bool:false(the default) imports a relation whose target is not in this import as a plain column, with a# PormG:marker.trueraisesInvalidMigrationErrorinstead. See Relation targets.strict_fields::Bool:false(the default) skips a field whose Django type PormG does not implement, or anArrayFieldwhose element PormG'sArrayFieldcannot hold, with a@warnand a# PormG:marker naming the field, its class and itsmodels.pyline.trueraisesInvalidMigrationErrorinstead. The lenient default is not about tolerating one bad column — before it, a single unimplemented type aborted the import of every model in every app of the call. See the note under Supported Django Fields.binding_overrides::AbstractDict: spell a generated Julia binding differently from the derived one. See Choosing your own binding.
Neither output_path nor django_prefix mutates the shared db config: when either is set, a throwaway render-only Settings (no database connection) carries them.
Relation targets
Every ForeignKey / OneToOneField / ManyToManyField target is resolved to the Julia binding of the model it names, because that is the only thing PormG can resolve at load time — set_models looks a target up as a binding in the generated module and nothing else. Four spellings work:
| Django source | resolves to |
|---|---|
ForeignKey(Circuit, …) or ForeignKey("Circuit", …) | the class named, in this app first, then a project-unique one |
ForeignKey("self", …) | the declaring class |
ForeignKey("racing.Circuit", …) | that app's class. Both halves match case-insensitively — Django lowercases the model name and app labels are lowercase by convention, so the extra tolerance on the label can only help |
ForeignKey(settings.AUTH_USER_MODEL, …) | the project's user model — see auth_user_model |
An unqualified name that two apps declare is an error naming both; qualify it as Django does.
Targets outside the import
A project that touches django.contrib references models it did not hand you. Those degrade rather than fail: the column is real — Django created it — so it is imported as a plain BigIntegerField and the artifact says what was lost.
# PormG: field 'created_by_id' on 'imports.ImportBatch' — ForeignKey target
# 'contenttypes.ContentType' is not in the imported app set; imported as a plain column,
# the relation is lost.
created_by_id = Models.BigIntegerField(blank=true, null=true)A ManyToManyField has no column of its own, so an unresolvable one — target or through= model — is dropped and marked instead.
Pass strict_relations = true to raise InvalidMigrationError on the first such target instead. That is the right setting once you believe every app is in the import; it is not the default, because it would make the importer unusable on any project that references django.contrib.
The one target that is always a hard error is settings.AUTH_USER_MODEL when the importer cannot tell which model it is (no AbstractUser subclass, or more than one, and no auth_user_model). Nearly every model in a real project points at the user model, so one omitted keyword would quietly turn the whole user graph into integer columns.
An auto-derived ManyToManyField whose join column would contain __, PormG's lookup separator, is also dropped and marked, whatever strict_relations says: the target resolves, but PormG cannot name the column. Django names those columns from the lowercased class name: <class>_id, or from_<class>_id / to_<class>_id on a self-relation. A class whose name starts or ends with _ or contains __ therefore yields one: _Driver's self-relation gives from__driver_id. Django's own checks refuse those class names too (models.E023, models.E024). To keep the relation, declare its through model by hand:
- set its
db_tableto Django's join table, which the marker names when the import knows it; - give it one
ForeignKeyper end, each naming the column withdb_column = "from__driver_id"; - pin
source_field/target_fieldto those keys; - when the class that declares the
ManyToManyFieldends with_or contains__, also give the field arelated_name, for the reason in the next paragraph.
A ManyToManyField that already declares through= on such a class is imported, not dropped. PormG would name its key from the class name too (driver__id), so the importer pins source_field / target_field to the through model's key to each end and gives the field a related_name, as the next paragraph describes. When the through model has several keys to one end and no through_fields, the field is dropped with a marker: name the two keys with through_fields in models.py.
Django's through_fields = ("team", "driver") is read on every ManyToManyField that declares through=. It becomes source_field = "team_id", target_field = "driver_id": the through model's keys, which the importer spells <field>_id for a ForeignKey. A pin that names no foreign key to its end on the through model drops the field with a marker, because set_models would refuse it. Without through= there are no through keys to name, so through_fields is dropped with a marker, as Django refuses it too (fields.E337).
A ForeignKey, OneToOneField or through= ManyToManyField on such a class keeps its column and its relation. Only its reverse name changes. With no related_name, the reverse accessor PormG derives is the lowercased model name (driver_ for Driver_, pit__stop for Pit__stop), or <model>_<field> when the model has several relations to one target. A name that ends with _ or contains __ can never be a lookup-path segment, so set_models would refuse it. The importer writes a related_name instead and says so in a marker. The name is the derived one with __ collapsed and the trailing _ removed (driver, pit_stop, driver_home_id), with a digit added when the target already uses that name (driver2). Query the reverse side by that name:
# PormG: 'Driver_.circuit_id' is imported with related_name="driver" — the reverse accessor PormG
# derives for it on 'Circuit', 'driver_', ends with '_', so it can never be a lookup-path segment
# (Django refuses the class name too: models.E023). …
Driver_::Models.ModelType = Models.Model(
circuit_id = Models.ForeignKey("Circuit", pk_field="id", on_delete=CASCADE, related_name="driver"))Importing a multi-app project
A real Django project splits its models across apps. Pass one "<app_label>" => "<models.py>" pair per app:
import_models_from_django(
["racing" => "server/racing/models.py",
"access" => "server/access/models.py",
"imports" => "server/imports/models.py"];
db = "f1", file = "models.jl", force_replace = true)Everything lands in one generated module, and each model's table carries its own app label:
Circuit::Models.ModelType = Models.Model(db_table = "racing_circuit", …)
User::Models.ModelType = Models.Model(db_table = "access_user", …)
ImportBatch::Models.ModelType = Models.Model(db_table = "imports_importbatch",
circuit_id = Models.ForeignKey("Circuit", pk_field="id", on_delete=CASCADE),
steward_id = Models.ForeignKey("User", pk_field="id", on_delete=PROTECT))PormG is structurally one models file per connection: makemigrations and migrate resolve a single <db>/<model_file> and load it into a single module. A module per app would not merely be unsupported — the migration engine would never see it. Emitting the whole project into one module is also what makes a cross-app ForeignKey("racing.Circuit") resolvable at all, since resolution is a binding lookup in that one module.
Definition order inside the file does not matter: a ForeignKey holds its target as a String and only resolves after the whole module body has run.
Importing the apps together also merges an abstract base declared in another app — the shared core.models.TimeStampedModel shape — and resolves a TextChoices enumeration the same way.
A class from another app has to be imported, exactly as Python requires. Each app's own classes win, so a name two apps both declare resolves locally, as it does in Django; anything else is looked up through that models.py's own from … import … lines:
# imports/models.py
from core.models import TimeStampedModel # <- this line is what makes the merge happen
class ImportBatch(TimeStampedModel):
note = models.CharField(max_length=40)Without the import the base is reported as unresolved and the model keeps only its own columns — which is also what Python would do, since the name would not be defined. The lookup accepts the spellings a real project uses: an alias (import TimeStampedModel as TSM), a star import, a re-export through another app, and a nested package (from apps.core.models import …).
A module path names an app when the app's own name is followed by its models module — core.models, core.models.base — or by nothing at all, the package re-exporting through its __init__.py (from core import …). The name matched is the app's label or its package directory, because Django's AppConfig.label is frequently not the directory name: "crm" => "server/customers/models.py" resolves from customers.models import … as well as from crm.models import ….
Anything written in front of that name has to be where the app actually lives. A project laid out as apps/core/ resolves from apps.core.models import …; the same import against an app at server/core/ does not, and neither does from wagtail.core.models import Page — Wagtail is not your core app, however much the path looks alike. from core.forms import SeasonTicket is refused too: a sibling module is not the models module.
A single leading dot is this app's own package and never names another app, so from .core.models import … stays local; two or more ascend, so apps/{core,shop}/ can write from ..core.models import … and have it resolve.
The package directory can only be read from a path, and only as deeply as you spell it. An app handed over as source text ("crm" => read("customers/models.py", String)) is known by its label alone, so neither from customers.models import … nor any qualified spelling reaches it. Equally, an app passed as "core/models.py" cannot match from apps.core.models import … even when Python roots it under apps/ — pass "apps/core/models.py". Either way the failure is an unresolved base with a marker, never a wrong match.
Matching bases by name alone meant a base one app inherited from a third-party library resolved against an unrelated app's class of the same name. If that class was concrete, the child was read as multi-table inheritance and not imported — so adding an app to the list removed a table belonging to another app, which Python's per-module namespaces make impossible. Resolution now follows the import statements, so a name imported from outside the app set can no longer collide with an app's class. When a base is left unresolved and another app does declare that name, the marker says so and names the app — and says whether the module failed to import it or imported it from somewhere else entirely, because those want different fixes.
A django_prefix on the connection is rejected here rather than ignored. It is one value for the whole connection, and relationship accessor names strip it — so racing_circuit would become circuit while access_user survived intact, and half the project's reverse lookups would point at names that do not exist. Remove it from the connection's config; a single-app import that still wants it can pass django_prefix= to that call.
When two apps use the same class name
Both sides are app-qualified — never just the second one:
# server/racing/models.py # server/access/models.py
class Driver(models.Model): ... class Driver(models.Model): ...Racing_driver::Models.ModelType = Models.Model(db_table = "racing_driver", …) # logical name "racing_driver"
Access_driver::Models.ModelType = Models.Model(db_table = "access_driver", …) # logical name "access_driver"Renaming only the loser would make the output depend on the order you listed the apps, and PormG keys a model's reverse accessor on its logical name — so one Driver would answer to driver and the other to driver2, decided by list order. The rename is lossless because db_table still names the real table. A class name only one app declares is left alone.
Names that differ only in case collide too — Spectator and SPECTATOR derive different Julia bindings but the same logical name — so both are qualified on the same rule.
One residue is left, and it is reported rather than hidden: when two app-qualified names still collide (an app literally called racing_pit alongside racing's Pit), the second gets a digit suffix, and which one is second depends on the order you listed the apps. The generated file carries a # PormG: line naming what it collided with; binding_overrides picks the spelling if you care which is which.
Choosing your own binding
binding_overrides replaces the derived name for any model, collision or not:
import_models_from_django(pairs; binding_overrides = Dict("access.Driver" => "DriverLicence"))
# DriverLicence::Models.ModelType = Models.Model(db_table = "access_driver", …) # logical name "driverlicence"The value becomes the model's name, so the binding, the logical name and the reverse accessor all follow it, while db_table keeps naming the real table. It must be a legal, capitalized Julia identifier that no other model claims — every violation raises InvalidMigrationError rather than falling back to something else, because an override is an explicit instruction and one that silently does something different is worse than none.
The app prefix
Django names a model's table <app_label>_<lowercased class name>. PormG carries that prefix as the model's db_table, while the logical name stays the class name lowercased — left for loading to derive from the binding:
# myapp/models.py
class Pit_lane(models.Model):
full_name = models.CharField(max_length=50)
class PitWall(models.Model):
city = models.CharField(max_length=10)
lanes = models.ManyToManyField(Pit_lane)imported with django_prefix = "myapp" becomes:
Pit_lane::Models.ModelType = Models.Model(db_table = "myapp_pit_lane",
id = Models.IDField(),
full_name = Models.CharField(max_length=50))
PitWall::Models.ModelType = Models.Model(db_table = "myapp_pitwall",
id = Models.IDField(),
city = Models.CharField(max_length=10),
lanes = Models.ManyToManyField("Pit_lane", db_table="myapp_pitwall_lanes"))Three things to read off that output, and a fourth that shows up only when something is reported:
- The logical name is
class.__name__.lower()— Django's own derivation.PitWallbecomes"pitwall", with no underscore inserted, because that is what Django's table is called. Whichever class-naming convention the project uses, the derived name matches. That is also exactly what loading derives from the binding when a model has no positional name, so the importer leaves it out — the no-name form the hand-written examples use. A renamed model (a collision,binding_overrides) takes the short form too, because the new name is its binding lowercased. The one class that keeps it is a leading-underscore one (_Internal). A PormG model name never starts with_, so the importer always pins such a class's table, with or without an app label:_Internal = Models.Model("internal", db_table = "_internal", …)in an unlabelled import. The name"internal"is not what the binding_Internalderives, so it stays written. If the same import also has a plainInternal, the plain class keeps"internal"and_Internalis renamed_Internal2with a# PormG:line, whichever class is declared first.inspectdboutput always writes the name, because that file is edited by hand afterwards and a renamed binding there must not rename the model. - The Julia binding is the class name, unchanged. It never carried the prefix, so
M.Pit_lanein a consuming app is unaffected. - An auto-derived
ManyToManyFieldgets its join table pinned to Django's spelling, which is<the owning model's table>_<field>— somyapp_pitwall_laneshere, butf1_enrolment_legacy_crewsfor a class declaringMeta.db_table = "f1_enrolment_legacy". PormG's own derivation is<logical model>_<field>, which addresses a table Django never created. Adb_table=written on the field is left alone, and a field withthrough=is skipped entirely — Django ignoresdb_tablethere, because the join table is the through model's table. Its join columns follow Django's rule too, and are pinned assource_field/target_fieldwherever PormG would spell them differently. A self-relation (from_driver_id/to_driver_id) is decided by name: the class the field names against the owner's class, ignoring the app label. So"self","Driver"onDriverand"imports.Driver"onracing.Driverare self-relations, whileManyToManyField("DriverProxy")onDriveris not, because a proxy keeps its own name. Its columns aredriver_id/driverproxy_id. - Anything the importer reports names the class
'myapp.PitWall', not'PitWall'— adjango_prefixis the Django app label, so there is a label to qualify with. See Meta Options.
Without a django_prefix, no app label is known, so the models import unprefixed and there is no prefix to write into db_table. A model still gets one when the derived name would not reach its real table: an explicit Meta.db_table, a renamed model (a collision or binding_overrides), or a leading-underscore class, as described above.
django_prefix is one value per connection, so it can only ever express one app label. A real Django project splits models across core_, access_ and imports_ at once — and PormG is structurally one models file per database, not per app. Carrying the prefix per model is what lets a single generated file hold every app in the project, which is what Importing a multi-app project does. django_prefix is the single-app spelling of the same thing; the multi-app arity takes the label from each pair instead and rejects a configured prefix.
Two things still read it: relationship accessor names strip it, and it is the fallback that spells the physical table of a reverse-join target declaring no db_table. Both become no-ops once every model in the file carries a db_table, but a hand-written model on the same connection still relies on the fallback, so there is no reason to unset it. It does not gate Django-style short-form join paths ("driver__forename") — those work on every connection.
The older spelling, Models.Model("myapp_pit_lane", …), still addresses the table myapp_pit_lane. The new form only appears when you regenerate — see the change log for the one case that forces an edit. A file that writes the logical name out, Models.Model("pit_lane", db_table = …), loads the very same model as the short form; regenerating changes its text and nothing else.
Supported Django Fields
Every Django field type PormG implements is listed below, and the list is exhaustive: a models.X that is not on it has no PormG counterpart, and the note at the end of this section says what happens to it. Every entry except the three auto-key types maps by identity — same name, same column.
Text Fields
CharField→CharFieldTextField→TextFieldEmailField→EmailFieldSlugField→SlugFieldURLField→URLField
Numeric Fields
IntegerField→IntegerFieldPositiveIntegerField→PositiveIntegerFieldPositiveSmallIntegerField→PositiveSmallIntegerFieldBigIntegerField→BigIntegerFieldFloatField→FloatFieldDecimalField→DecimalField
Date/Time Fields
DateField→DateFieldDateTimeField→DateTimeFieldTimeField→TimeFieldDurationField→DurationField
Boolean Fields
BooleanField→BooleanField
Relationship Fields
ForeignKey→ForeignKeyOneToOneField→OneToOneFieldManyToManyField→ManyToManyField
Special Fields
AutoField→IDField(reported — see below)BigAutoField→IDFieldSmallAutoField→IDField(reported — see below) (PormG has noAutoFieldof its own — it was retired; see Field Types.)UUIDField→UUIDFieldGenericIPAddressField→GenericIPAddressField, withprotocolandunpack_ipv4(PostgreSQL only: on SQLite and MySQL,makemigrationsrefuses it)JSONField→JSONFieldBinaryField→BinaryFieldFileField→FileFieldImageField→ImageFieldArrayField→ArrayField, read through its element (PostgreSQL only — see below)SearchVectorField→SearchVectorField, bare orsearch.-qualified asdjango.contrib.postgres.searchspells it (PostgreSQL only: on SQLite and MySQL,makemigrationsrefuses it)
Django's ArrayField takes its element as a field call, and the importer reads that call like any other field — its max_length, max_digits, decimal_places, null and choices (enumerations included) carry over:
from django.contrib.postgres.fields import ArrayField
class RaceStrategy(models.Model):
tyre_compounds = ArrayField(models.CharField(max_length=10), size=8)
pit_laps = ArrayField(models.IntegerField(), default=list)imports as
tyre_compounds = Models.ArrayField(Models.CharField(max_length=10), size=8),
pit_laps = Models.ArrayField(Models.IntegerField(), default="{}")The bare ArrayField(...) is recognised as well as models.ArrayField(...) or any other module prefix (fields.ArrayField(...)), and the element may be passed as base_field=. A second positional argument is Django's size, and imports as size=. Django's default=list becomes the empty-array default, which PormG stores as the literal "{}". A list or tuple of literals is the array it denotes: default=["SOFT", "HARD"] imports as default="{SOFT,HARD}". A list holding anything else (a call, a name) is dropped and reported.
A column option on the element (db_index, unique, default, …) is dropped and reported: Django ignores it there, and PormG refuses it — see Array Fields.
An element PormG's ArrayField cannot hold — a JSONField, a relation, a nested ArrayField, or a type PormG does not implement — is reported and skipped like an unimplemented type (next note), and the report names the element. So is an ArrayField whose element is not a call the importer can read, such as a module constant.
BigAutoField is Django 3.2+'s DEFAULT_AUTO_FIELD. It is BIGINT auto-increment, which is exactly what IDField is, so it imports silently.
AutoField (INTEGER) and SmallAutoField (SMALLINT) map to IDField too, because IDField is PormG's only integer key type. PormG used to ship an AutoField of its own; it was retired because it emitted a TEXT column and could never converge. Django's AutoField and PormG's were never the same thing, and now only one of the two names exists.
Both are annotated in the generated file, so the substitution is visible. Your existing column is not re-typed — but a table PormG creates from these models gets BIGINT rather than INTEGER or SMALLINT.
Django ships plenty of types PormG has no counterpart for — SmallIntegerField, PositiveBigIntegerField, FilePathField, GeneratedField. Each one costs its own column and nothing else: every other field, every other class and every other app in the same call still import. The skip is reported both ways, with a @warn and a # PormG: marker naming the field, its class and its models.py line.
Mind the consequence: the skipped column is still in the database and now absent from the model, so makemigrations reads it as drift and proposes dropping it. Declare that column by hand before you migrate — or pass strict_fields = true to fail the import instead of receiving a model with a column missing.
A field class the project defines itself is read through its base:
class DriverCodeField(models.CharField):
def get_prep_value(self, value):
return value.upper() if value else value
class Driver(models.Model):
code = DriverCodeField(max_length=3, null=True)code imports as Models.CharField(max_length=3, null=true). The column's type, length, nullability and default all come from the call. The chain is followed transitively (class B(A), class A(models.CharField)), and so is a class another app of the same import defines and this models.py imports (from core.models import DriverCodeField). A bare base counts only when it was imported from Django (from django.db.models import CharField).
A # PormG: marker names the class, because its Python-side behaviour (pre_save, get_prep_value, validators) has no PormG counterpart. If the class overrides __init__, the marker also warns that __init__ may supply options, such as a max_length, that the call does not show. Compare that declaration against the column before you migrate.
Two cases are not imported, and they keep the "field-shaped call the importer cannot read" marker with the reason added:
- a class in the chain overrides
db_type,db_parameters,get_internal_type,rel_db_typeorcontribute_to_class. The column is then not the base's type, and declaring it anyway would handmakemigrationsa wrongALTER; - a class in the chain also inherits a base the importer cannot see (a mixin from a library), which could override the column just as well.
A class whose base is not a Django field at all (a third-party field, models.Field itself) keeps the plain marker. autofields_ignore accepts the custom class's name as well as its Django base type.
Field Mapping
Parameter Conversion
Django parameters are automatically converted to PormG equivalents:
| Django Parameter | PormG Parameter | Notes |
|---|---|---|
max_length | max_length | Direct mapping |
null=True | null=true | Boolean conversion |
blank=True | blank=true | Boolean conversion |
unique=True | unique=true | Boolean conversion |
default=value | default=value | Value conversion — True/False/None, numbers, and one quoted string literal, including one written across a line continuation. A value written as a string but not readable as one (a concatenation, an f-string, a raw or triple-quoted literal), and a call (uuid.uuid4(), Decimal('0.00')), are kept verbatim as text and reported. See String defaults. |
db_default=value | default=value or db_default="CURRENT_TIMESTAMP" | A literal becomes default, Now() becomes CURRENT_TIMESTAMP, anything else is reported and not imported — see Database defaults |
on_delete=CASCADE | on_delete=CASCADE | Direct mapping |
choices=[…] | choices=(…) | List to tuple; also resolves TextChoices/IntegerChoices — see Choices |
max_length=MAX_LEN | max_length=10 | A name is read as the constant it holds — see Options written as a name |
| Any option the PormG field does not take | — | Dropped and reported (#1333). A PormG field constructor refuses a keyword it does not accept, so the importer drops the option before it builds the field, with a warning and a # PormG: comment naming the class, field and option: an EmailField's max_length, a TextField's max_length, a ForeignKey's to_field, db_comment or related_query_name, a ManyToManyField's symmetrical. A primary_key=True is reported by the lost-key comment instead (see Automatic Additions). |
related_name='name' | related_name="name" | Direct mapping. Refused if it contains __ or @, or ends with _ — __ is PormG's lookup-path separator, and traversing an accessor appends it, so any of the three registers a name that can then never be written as a lookup-path segment. Django refuses the same names through system checks fields.E309 and fields.E308, so a project that passes manage.py check cannot hit this. |
Options written as a name
An option value written as a bare name — max_length=MAX_LEN, choices=STATUS_CHOICES, size=MAX_TAGS — is read as the constant it holds, when the same models.py assigns it a literal above the field, at module level or in the class body:
MAX_LEN = 10
STATUS_CHOICES = (("A", "Active"), ("I", "Inactive"))
class Driver(models.Model):
CODE_LEN = 3
surname = models.CharField(max_length=MAX_LEN)
code = models.CharField(max_length=CODE_LEN)
status = models.CharField(max_length=1, choices=STATUS_CHOICES)imports as max_length=10, max_length=3 and the two choices. This is Python's own lookup for a class body: the class's namespace, then the module's. A name bound to another name (ALIAS = MAX_LEN) is followed. A field inherited from an abstract base reads the constants of the module the base is written in, as an enumeration does.
A name the importer cannot read as a literal is dropped and reported, never kept as text:
- a name imported from another module (
from .constants import MAX_LEN) — imports are not followed, because a constants module is not part of the import. That includes a name afrom … import *may have rebound; - a name assigned only after the class, which Python would not have bound yet, or rebound later to something that is not a literal (
MAX_LEN = compute()), or rebound inside anif/tryblock that may not have run (try: from .local_settings import MAX_LEN); - a tuple or list built by an expression (
BASE + EXTRA, a comprehension), where only a literal display is read; - a callable such as
default=uuid4.
Before this, such a value reached the schema as text: db_column=COL named a column "COL". The column survives without the option, and the # PormG: marker names the field, the class, the option and the name. Declare the option by hand if you need it. on_delete=CASCADE, a relation's to=/through=, and default=list / default=dict are symbols, not constants, and import as before.
A constant that resolves to a value the field refuses (MAX_LEN = "ten") costs that option, not the import: the field is rebuilt without it and reported, like a choices/default pair that disagrees. A field that still cannot be built stops the import with the field's own error, which names the field and the class.
Meta Options
Django Meta option | Outcome | Notes |
|---|---|---|
db_table = "x" | imported as db_table = "x" | The physical table. Absolute, as in Django: it overrides the derived name and a configured django_prefix. The logical name stays the derived one ("enrolment", from the binding) either way — see The app prefix. |
unique_together = ('a', 'b') | imported as constraints = [Models.UniqueConstraint(fields = ("a", "b"))] | A tuple-of-tuples (several composite keys) becomes one UniqueConstraint per group. |
constraints = [UniqueConstraint(fields=…, name=…)] | imported | The modern spelling. See the acceptance rule below — it is narrower than Django's. |
constraints = [UniqueConstraint(fields=…, condition=Q(…), name=…)], UniqueConstraint(Lower('x'), name=…) | imported as Models.UniqueConstraint(fields = …, name = …, condition = "<SQL>") / Models.UniqueConstraint(expressions = (…,), name = …) | A partial or functional unique constraint, translated exactly as a partial or functional Index is; the generated file notes it carries Django's SQL (see the note below the table). |
constraints = [CheckConstraint(…)] | converted when the Q(...) is simple; otherwise reported, as a stub | The condition becomes SQL over the table's physical columns, each written bare, so it names the column on PostgreSQL, SQLite and MySQL alike: check=Q(laps__gte=0) & Q(grid__lte=F("laps")) imports as Models.CheckConstraint(condition = "laps >= 0 AND grid <= laps", name = "…"). A column whose name some engine reserves (user, order) or that is not in lower case keeps Django's double quotes, which PostgreSQL and SQLite read as the column; on MySQL a double-quoted name is a string literal and such a name has no spelling every engine reads as the column, so makemigrations there refuses the model (#1368) — give the column a plain lower-case name, with db_column. Converted: exact and in on text, number and boolean fields; gt, gte, lt, lte and range on number fields, with whole-number values on an integer field (Django runs int() on the value, so laps__gt=1.5 there means > 1); isnull=True/False and =None on any field; F("field") against a field of the same kind; a boolean field compared to True / False is written as Django renders it, the bare column, enabled / NOT enabled; all combined with &, ` |
abstract = True | no table | The class becomes a base: its fields merge into every child. See Model inheritance. |
proxy = True | no table | A proxy shares its parent's table; emitting one would declare that table twice. A relation to the proxy addresses the parent's model, whether the base is written by its own name, through an import alias (from racing.models import Driver as RacingDriver), or through a module (racing_models.Driver after from racing import models as racing_models, racing.models.Driver after import racing.models). |
managed = False | imported as managed = false | A model PormG queries but never migrates — see Unmanaged models. Inherited from an abstract base, as in Django. A value other than True/False (a setting, an expression) is reported and the model stays managed. A managed model's ForeignKey into an unmanaged one is imported with db_constraint = false, and reported: PormG refuses a constrained key into a table it does not migrate, which may be a view. |
indexes = [Index(fields=['a','b'])] | imported as indexes = [Models.Index(fields = ("a", "b"))] | See the acceptance rule below — narrower than Django's. |
indexes = [Index(fields=['a'])] | imported as a = …(db_index=true) | A plain one-column index is db_index, and is the only spelling that round-trips, so nothing is reported. Two exceptions: on the primary key it is redundant (already indexed) and skipped, and on a field type with no db_index option (PasswordField) it is dropped and reported. A one-column index with a -, a PostgreSQL index class or opclasses= is a different index and stays a Models.Index. |
indexes = [Index(fields=['a', '-b'])] | imported as Models.Index(fields = ("a", "-b")) | A descending column keeps its -. |
indexes = [GinIndex(fields=['a'], opclasses=['gin_trgm_ops'], name=…)] | imported as Models.Index(fields = ("a",), name = …, method = "gin", opclasses = ("gin_trgm_ops",)) | Likewise BTreeIndex, HashIndex, GistIndex, SpGistIndex and BrinIndex from django.contrib.postgres.indexes, each as its method. These are PostgreSQL-only: makemigrations on SQLite and MySQL refuses the model. |
indexes = [Index(Lower('name'), name=…)] | imported as Models.Index(expressions = ("LOWER(name)",), name = …) | A functional index, in Django's own spelling of the SQL, with the column written bare as a CheckConstraint's is. Translated: F('a'), Lower(…) and Upper(…) over a text field or an F(), each optionally .desc() / .asc(). Anything else is reported (below). |
indexes = [Index(fields=['a', 'b'], condition=Q(…), name=…)] | imported as Models.Index(fields = ("a", "b"), name = …, condition = "<SQL>") | A partial index. The Q(...) goes through the same translator as a CheckConstraint's, with the same whitelist; one it cannot translate drops the index and reports why. |
indexes = [Index(fields=['a'], include=['b'], name=…)] | imported as Models.Index(fields = ("a",), name = …, include = ("b",)) | A covering index: include= carries over as field names. PostgreSQL-only, like the contrib.postgres classes: makemigrations on SQLite and MySQL refuses the model. |
index_together = (('a','b'), …) | imported as one Models.Index per group | The legacy spelling; the non-unique twin of unique_together. |
ordering, get_latest_by | dropped, reported | PormG orders per query, not per model. |
verbose_name*, permissions, default_related_name, app_label, … | dropped, reported | No PormG equivalent. |
| anything unrecognised | dropped, reported | A typo or a Django option this importer has not met. Neither is safe to pass over quietly. |
The SQL the importer writes is Django's own spelling, which is what Django sends to the database, with each plain lower-case column name written bare rather than double-quoted. A boolean field compared to True or False is written the way Django renders it — the bare column, enabled, or NOT enabled — never = TRUE, in a CheckConstraint and a partial index alike. SQLite stores Django's text as it was sent, quotes included, and makemigrations compares a double-quoted plain name and the bare one as the same column, so on a SQLite database Django built, such a CHECK or partial index matches the imported declaration outright.
PostgreSQL, though, stores a rewritten form of the text (lower(nickname::text) for LOWER(nickname)). Against a PostgreSQL database Django already built, makemigrations reads such an index as hand-made and refuses to take its name for the importer's text, with InvalidMigrationError — printing the declaration, in the database's own text, that adopts the index instead. Replace the imported declaration with that one, or generate the models with generate_models_from_db, which writes the database's text from the start. The generated file says so beside every such index. On a database PormG creates from the imported models, nothing changes on either engine: the declaration is the index.
"Reported" means two things at once: a @warn at import time and a # PormG: comment on the line above the model in the generated file. The console warning scrolls away; the comment is still there when someone reads the file six months later.
Both halves name the class app-qualified whenever an app label is known — 'imports.ImportBatch', the same "<app_label>.<Class>" spelling Django uses in a Meta reference. That is what lets a report be traced back to one model in a project where two apps declare a Spectator: the generated file holds Core_spectator and Access_spectator, and nothing called Spectator. An unlabelled single-app import — no django_prefix — has no label and nothing to disambiguate against, so it uses the bare class name.
Two things that qualification does not touch. The report names the Python class, never the Julia binding, so a model renamed by a collision or by binding_overrides is still reported under the name its models.py actually uses. And a name quoted because the source wrote it — a base class, an enum, a field — stays verbatim: only the class a report is about is qualified.
A UniqueConstraint is imported only when its arguments are within fields, name, condition, violation_error_message, violation_error_code, plus positional expressions. A condition=Q(...) goes through the CheckConstraint translator and a positional expression (UniqueConstraint(Lower("name"), …)) through the functional-index one, each only when it translates exactly, and both need a name= string literal. Anything else — nulls_distinct, deferrable, include, opclasses, a Q(...) or an expression outside those whitelists — causes that one constraint to be dropped and reported; its siblings on the same model are unaffected.
The direction is deliberate. A Django partial index (condition=Q(active=True)) imported as an unconditional one would start silently rejecting rows the live database accepts. Refusing an option is recoverable; reinterpreting one is not.
An entry is imported only when it is a models.Index or one of the django.contrib.postgres index classes above, and its arguments are within fields, name, opclasses (a list of string literals) and condition, plus positional expressions. A - in fields is a descending column. Everything else causes that one index to be dropped and reported, leaving its siblings alone:
- a positional expression other than
F(),Lower()andUpper()over a field —Collate,OpClass, arithmetic, any other function — and acondition=Q(...)outside theCheckConstrainttranslator's whitelist. Importing the columns alone would build a different index under the developer's name; the report namesModels.Index(expressions = …), so the index can be declared by hand; - a functional, partial or covering index without a string-literal
name=, and one mixing expressions withfields=oropclasses=— Django refuses all of these itself; db_tablespace=, and an index class's storage parameters (fastupdate=,gin_pending_list_limit=,pages_per_range=,fillfactor=, …) — each changes what is indexed, how, or where it lives;- any other class, such as
BloomIndex(an extension's access method) or a project's own subclass; - a descending column in
index_together, which has no such spelling.
An index name is unique per database, so two models declaring the same name cannot both be created — makemigrations refuses the plan outright. An abstract base makes that easy to write without noticing: Django installs the base's whole Meta on every child that declares none of its own, so one indexes = [Index(fields=…, name="base_x")] reaches every child. Django rejects it at system-check time (models.E030); this importer keeps the index on every child and drops the duplicated name, deriving <table>_<cols>_idx per table instead, and reports which name was surrendered. The same rule applies to a reused UniqueConstraint name.
Because foreign-key fields are imported with an _id suffix (item → item_id), the importer resolves each declared member — in unique_together, constraints, indexes and index_together alike — to its imported field name automatically:
class Car_part_maker(models.Model):
item = models.ForeignKey(Car_part, on_delete=models.CASCADE)
maker = models.ForeignKey(Part_maker, on_delete=models.CASCADE)
class Meta:
unique_together = ('item', 'maker')imports as:
Car_part_maker::Models.ModelType = Models.Model(
id = Models.IDField(),
item_id = Models.ForeignKey("Car_part", pk_field="id", on_delete=CASCADE),
maker_id = Models.ForeignKey("Part_maker", pk_field="id", on_delete=CASCADE),
constraints = [Models.UniqueConstraint(fields = ("item_id", "maker_id",))])See Composite Uniqueness for how the constraint is materialized.
Model inheritance
A class becomes a table when its base list resolves to one — not when it matches a fixed spelling. class SeasonTicket(TimeStampedModel): is imported; so is class User(AbstractUser, SomeMixin).
| Shape | Outcome |
|---|---|
class Base(models.Model) with Meta.abstract = True | No table. Its fields merge into every child, and a child that redeclares a field wins. |
class Child(Base) where Base is abstract | One table carrying Base's fields plus its own. |
class Child(Base) where Base is concrete | Not imported. See below. |
class Child(models.Model, PlainMixin) | One table. Django collects fields only from bases that are themselves models, so a plain mixin contributes none — and PormG matches that. |
class Child(Base) where Base is in another file | Imported with its own fields only, plus a marker naming the missing base — provided the body declares at least one field (see the row below). |
class X(models.QuerySet / models.Manager / models.TextChoices / object) | Skipped, silently — these are not tables. A class this file defines always wins over that list, so your own model named Manager is imported normally. |
any class declaring Meta.model = X | Skipped, silently. That is the signature of a ModelForm, serializer or FilterSet; Django's own ModelBase rejects the attribute on a real model. |
| any other unrecognised base | Imported only if the body declares at least one database field — see the namespace rule below. A class with only methods and a Meta declares none, so it stays out. |
A field counts toward that last rule when it is called through a model namespace: models.CharField(...), db.models.CharField(...), the fully qualified django.db.models.CharField(...), or a directly-imported bare CharField(...). A forms.CharField(...) or serializers.CharField(...) does not.
That distinction is the whole guard for a plain forms.Form or DRF serializers.Serializer — neither declares Meta.model, and their members are field-shaped, so without it both import as id-only tables that reach makemigrations as real CREATE TABLEs. Silent junk in the schema is the mirror of silent loss, and the worse of the two. One consequence worth knowing: an aliased import (from django.db import models as db_models) is not recognised, so db_models.CharField(...) does not count as a field for this rule — it only matters for a class that also has an unresolvable base and no plainly-spelled field.
Meta inheritance follows Django's rule, minus one footgun. A child that declares no Meta block at all inherits its abstract base's whole Meta. Two keys are withheld: abstract (Django resets it too), and db_table — Django does inherit that one, and the result is every child of the base pointing at a single table, so PormG refuses it and says so on each affected child.
Multi-table inheritance is refused, not approximated. For class SeasonTicket(Ticket) with a concrete Ticket, Django creates a child table whose primary key is a ticket_ptr_id one-to-one to the parent. PormG cannot express that. Merging the parent's fields would duplicate its columns; omitting them would lose the primary key. Both put a schema on disk that contradicts the live database, so the child is skipped and the generated file records why (markers are one line each; wrapped here to fit):
# PormG: model 'SeasonTicket' inherits the concrete model 'Ticket' (Django multi-table inheritance) —
# not imported. Django keys the child table on a 'ticket_ptr_id' one-to-one to the parent, which
# PormG cannot express.A base in another app degrades, it does not delete. One models.py cannot see from core.models import TimeStampedModel, so the child is imported without that base's columns and says so. The walk covers abstract ancestors too — a base that is itself missing a base loses columns the same way, and it emits nothing of its own to carry the marker:
# PormG: model 'RaceReport' inherits 'TimeStampedModel', not defined in this file — any fields
# declared there are MISSING below. Add them by hand, or pass every app of the project as
# "<app_label>" => "<models.py>" pairs so a base in another app is merged.
RaceReport::Models.ModelType = Models.Model(
id = Models.IDField(),
caption = Models.CharField(max_length=80))That is the unlabelled wording. Once the import carries an app label — a multi-app call, or a single-app one with a django_prefix — the same gap is reported as:
# PormG: model 'imports.RaceReport' inherits 'TimeStampedModel', not defined in any app of this
# import — any fields declared there are MISSING below. Add them by hand, or add the app that
# defines them to the pair list.Two changes, and only the first is general: the class is app-qualified, and this particular marker also switches "this file" for "any app of this import", because with a label the advice is different. 'TimeStampedModel' stays as written either way — it is quoted from the source, not the class being reported on.
Importing the defining app alongside this one resolves it — provided this models.py really does from core.models import TimeStampedModel, which is what the lookup follows. See Importing a multi-app project.
A class with no resolvable base and no column of its own is reported, not skipped quietly. Every field it might have lives in a base the importer cannot see, so there is no evidence either way and it produces no table — but saying nothing is how a real model disappears without a trace. The marker names the module the base was imported from, which is usually all it takes to tell the two apart:
# PormG: class 'MyManager' inherits 'InheritanceManager', imported from 'model_utils.managers',
# which nothing in this import defines, and declares no field of its own — not imported. …
# PormG: class 'SeasonTicket' inherits 'TimeStampedModel', imported from 'core.models', which nothing in
# this import defines, and declares no field of its own — not imported. …The first is a library and nothing is missing; the second says which app to add to the call. A base with no import line at all says that instead. A dotted base resolves when its module is an imported app's models module: class ImportBatch(core_models.TimeStampedModel) after from core import models as core_models, or class Pilot(racing.models.Driver) after import racing.models, reads exactly as the bare name would. Any other dotted base (forms.Form, serializers.Serializer) stays silent: it names a module outside the import, so it is a helper by construction, and the namespace is the same discriminator that keeps a ModelForm out of the schema.
Dropping the model instead would be the worse trade: a table that silently disappears is harder to notice than one that is present and annotated.
Choices
Both Django spellings are imported: an inline literal, and a TextChoices / IntegerChoices enumeration.
class ImportBatch(models.Model):
class Status(models.TextChoices):
DRAFT = "DRAFT", "Processing"
APPLIED = "APPLIED", "Committed"
IN_PROGRESS = "IN_PROGRESS" # label derived: "In Progress"
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
conduit = models.CharField(max_length=2, choices=(("UP", "Upload"), ("GS", "Sheets")))imports as (fields are emitted in sorted order, so conduit precedes status):
ImportBatch::Models.ModelType = Models.Model(
id = Models.IDField(),
conduit = Models.CharField(max_length=2, choices=(("UP", "Upload"), ("GS", "Sheets"))),
status = Models.CharField(max_length=20, default="DRAFT", choices=(("DRAFT", "Processing"), ("APPLIED", "Committed"), ("IN_PROGRESS", "In Progress"))))Enumerations. An enum is found whether it is nested inside the model, declared at module level, or declared on an abstract base the model inherits. Lookup is scoped in that order, so two models may each nest a Status with different members and each resolves to its own. A nested enum can also be addressed through its owner (ImportBatch.Status.choices), as Django allows.
Scoping is per statement, in the module the statement was written in — the rule Python itself uses. This matters once an abstract base lives in a different app from the model inheriting it, because such a model's fields come from two models.py files at once:
# core/models.py
class Status(models.TextChoices):
DRAFT = "d", "Draft"
class Base(models.Model):
phase = models.CharField(max_length=1, choices=Status.choices)
class Meta:
abstract = True
# shop/models.py
from core.models import Base
class Status(models.TextChoices): # a DIFFERENT Status
OPEN = "o", "Open"
class SeasonTicket(Base): # inherits `phase`
total = models.IntegerField()SeasonTicket.phase imports as choices=(("d", "Draft"),) — core's enumeration, because that is the module the statement was written in. shop's Status is a different name in a different namespace and never applies to it (#402).
The rule runs the other way too: a field shop declares itself cannot reach into core's module just because it inherits a base from there. Python would raise NameError — shop never imported the name — so the importer drops the option and marks it, rather than borrowing an enumeration the source never referenced.
Within one module the same ordering applies: a module-level enum outranks one nested on an abstract base, because a class body resolves a bare name against the module and never against a base class. Reach a base's enum deliberately with the qualified Base.Status.choices, which is attribute access and valid Python — addressed either by the base's own class name or by the as alias this module imported it under (CoreBase.Status.choices), since both are names your models.py actually bound. (A bare name matching only a base's nested enum is still resolved, as a convenience — Python would raise NameError there.)
The name has to be bound in the module that writes the reference — and only that. It does not have to be an as alias, and it does not have to be used as a base: from core.models import Base followed by Base.Status.choices in a class that inherits nothing resolves like any other module-level name, because that is what Python does with it.
"The module that writes the reference" is the statement's own module, not the class's. A field declared on an abstract base in core keeps resolving core's bindings when that statement is merged into a child in shop, so one statement gives one answer wherever it lands.
A name another module bound is still not resolved — shop inheriting Mixin from access does not put access's imports in shop's namespace, Python raises NameError there, and resolving it would hand a model an enumeration through a name its source never mentions (#370). The option is dropped and reported, as it is for any name the file does not define.
A module-level enum in another app follows the same rule as a base: it resolves only when the models.py that uses it imports it — alias, star import and re-export included, exactly as for a base. That gate is applied per app, so an abstract base whose own module imports its enum resolves normally when its statements are merged into a child elsewhere. Matching by name alone handed a model another app's enumeration with no marker anywhere — the wrong choices and default in the schema and nothing in the file to show it (#370).
A nested enum belongs to its owning class in its own app, so two apps may each declare a Base with its own nested Status and a child of either resolves to the right one.
| Reference | Result |
|---|---|
Status.choices | the full (value, label) set |
Status.MEMBER | that member's value — typically what default= uses |
Status.values / .labels / .names | dropped and reported: a flat list, not (value, label) pairs |
| a member the enum does not declare | dropped and reported |
an enum whose members are not literals (CAR = auto()) | dropped and reported — the importer cannot know the value, and keeping "auto()" would give every member the same one |
a numeric member (THOUSAND = 1_000, THIRTYONE = 0x1F) | imported as the value it denotes — 1000, 31 — not as the source spelling, which would declare an enumeration no row can match |
a class reached through a name the module imported (from core.models import Base as CoreBase then CoreBase.Status.choices) | resolves, aliased or not, and whether or not the class is also used as a base. The name is looked up in the module that wrote the statement — so an abstract base's own alias keeps working when its field is merged into a child in another app, and a name some other module bound stays unresolvable, exactly as Python's NameError says |
a name the module that wrote the statement does not define or import (from .enums import Status) | the option naming it is dropped and reported |
The dropped row is per option, not per field: if choices=Status.choices resolves and only default=External.ACTIVE does not, the field keeps its enumeration and loses just the default. Dropping the resolvable half as well would throw away what the source did give you. The reason neither is ever passed through as a literal is that default="External.ACTIVE" against an empty choices is exactly what used to make CharField reject the field and abort the entire import.
A member's own attributes work too: Status.NEW.value resolves to the stored value, while .label and .name are display text and the member name rather than a value, so they are dropped and reported.
The same rule catches a dotted name that is not an enum at all — a module constant such as default=constants.MAX_QTY — so the diagnostic says "this file does not define" rather than naming an enumeration it cannot verify. The test is the attribute's shape: .choices, .values, .labels, .names, .value, .label, .name, or a SHOUTY_CASE member. Any other dotted default (uuid.uuid4, timezone.now, an unconventionally-spelled External.active) is kept verbatim as text, exactly as before — and reported, because an expression landing in the schema as a literal default is not something to discover later.
The same treatment reaches a default that is not dotted at all but still is not one value — a concatenation such as default='a' + 'b'. See String defaults.
A choices naming a module-level constant (choices=STATUS_CHOICES) is read when the same models.py binds it to a literal above the class; otherwise the option is dropped and reported rather than becoming an empty enumeration. See Options written as a name.
choices needs a CharField. It is the only PormG field type with a choices slot, so a Django TextField(choices=…) imports as a plain TextField with the option dropped and reported. The column is unaffected.
An inline ("UP", "Upload") imports as the value UP. Before this, it was stored as the four-character string "UP" — the quote marks kept as part of the value. choices is Julia-side metadata that never reaches DDL, so nothing downstream misbehaved, but regenerating an existing import now produces the clean form.
Both containers, both pair styles. choices may be a tuple or a list, and so may each pair — [["A", "Alpha"]] imports correctly. A comma inside a label (("APPLIED", "Committed, with caveats")) is content, not a separator. An entry that is not a (value, label) pair at all is dropped and reported, and the well-formed entries beside it survive.
A label wrapped for translation is unwrapped. ("A", _("Alpha")) — and the gettext, gettext_lazy and ugettext* spellings — imports the label Alpha, the same as a TextChoices label does. The wrapper carries no schema meaning, and leaving it on put the source text _("Alpha") into the generated file as the display string.
An entry whose value or label is not one literal is kept and reported. A concatenation, an f-string or a call in either half — ('a' + 'b', 'Labelled') — has a value PormG cannot compute, so the entry keeps the source text and earns a # PormG: marker saying exactly that:
# PormG: field 'c' on 'SeasonTicket' has a choices entry the importer cannot read as literals — kept as
# the source text, not the value it denotes: ('a' + 'b', 'Labelled')
c = Models.CharField(max_length=20, choices=(("'a' + 'b'", "Labelled"), ("c", "Cee")))That is a different report from the one above it, deliberately: this entry was kept, not dropped, and saying "not a (value, label) pair" about it would be a true sentence about a different problem.
Numbers are literals, so an inline [(1, "One")] earns no report — and, like a numeric enum member, it is imported as the value it denotes: [(0x1F, "Hex"), (1_000, "Thousand")] gives "31" and "1000". Carrying the source spelling instead would declare an enumeration no row can match, and would let one generated file hold two spellings of the same concept — "0x1F" from an inline list and "31" from the IntegerChoices version of it.
The field itself may be wrapped across several lines — that is what black produces, and it is read correctly. A field whose declaration the importer cannot read is reported with a warning naming the field and its source line; it is never dropped silently.
String defaults
A quoted default is imported when it is one Python string literal, and only then. Accented and non-Latin text is ordinary content and needs nothing special:
city = models.CharField(max_length=60, default='São José')
nickname = models.CharField(max_length=60, default='Räikkönen')
city_jp = models.CharField(max_length=60, default='日本語')city = Models.CharField(max_length=60, default="São José")
nickname = Models.CharField(max_length=60, default="Räikkönen")
city_jp = Models.CharField(max_length=60, default="日本語")Escapes. The literal's own escapes are decoded: \\, \', \", \n, \r, \t, \a, \b, \f, \v. So default='it\'s quick' imports as it's quick. The numeric and named forms — \xHH, \uXXXX, \UXXXXXXXX, \N{…} and octal \ooo — are left exactly as written, backslash included, so default='caf\u00e9' imports as the nine characters caf\u00e9 rather than as café. That boundary is deliberate: a visible escape in the generated file is something you can correct, whereas a decoder guessing wrong about surrogates or octal is silent wrong data. Write the character itself and there is nothing to decode.
One literal, or nothing. A value whose first and last characters happen to be quotes is not necessarily one string. None of these has a value PormG can compute:
ref_code = models.TextField(default='a' + 'b') # explicit concatenation
acronym = models.TextField(default='it''s') # ADJACENT literals — Python reads this as "its"
tag_line = models.TextField(default=f'{tag_prefix}-x') # an f-string: its value lives at runtime
filepath = models.TextField(default=r'C:\temp') # raw and bytes literals escape differently
block_text = models.TextField(default="""three""") # triple-quotedEach is kept verbatim as text and reported, the same way default=uuid.uuid4 is (markers are one line each; wrapped here to fit):
# PormG: field 'ref_code' on 'Wheelman' has `default='a' + 'b'`, which the importer cannot read as
# one string literal — kept verbatim as text. The stored default is that source text, not the
# value it denotes.
ref_code = Models.TextField(default="'a' + 'b'")This matters most on CharField without choices and on TextField, the two types that accept any string: every other field type also rejects the value at construction and reports that separately.
A call is reported too. default=uuid.uuid4(), default=Decimal('0.00') and default=os.getenv('X') are values that exist only at runtime, so the source text is what gets stored — and on the two types above that string is accepted, which makes DEFAULT 'uuid.uuid4()' what a later migrate() would write:
# PormG: field 'x' on 'SeasonTicket' has `default=uuid.uuid4()`, a call the importer cannot evaluate —
# kept verbatim as text. The stored default is that source text, not the value the call returns.
x = Models.TextField(default="uuid.uuid4()")The bare default=uuid.uuid4 — the same name without the parentheses — is reported too, in slightly different words, because it is a different claim: a reference PormG cannot evaluate rather than a call it cannot run. The two never both fire for one value.
Note the asymmetry with choices: a translation wrapper is unwrapped in a choices label, but default=_('Blue') is reported as a call and kept verbatim. That is the same rule stated twice — a label is display text, a default is schema — and PormG will not guess at a value the column has to hold.
default=timezone.now() on a DateTimeField, DateField or TimeField is not in this category: it maps to auto_now_add=true, which is what it means. On any other field type there is no such translation, so it is reported like any other call.
Python joins the lines and drops the backslash, so
n = models.CharField(max_length=60, default='line1\
line2')imports as default="line1line2".
A raw literal is read by the same rule Python applies to it: r'…' has no line continuation, so the backslash and the newline are both content and both survive. The value is still kept verbatim and reported as unreadable, like any other raw literal — but the field that follows it is unaffected, which is the part that used to go wrong.
SQL doubles an interior quote, so 'it''s' is one SQL literal meaning it's. Python is not SQL: it reads the same text as two adjacent literals concatenated, meaning its. PormG applies Python's rule when reading a models.py and SQL's rule when reading a live schema. Write 'it\'s' — or "it's" — for an apostrophe in a Django default.
Database defaults
Django's db_default takes either a literal, which Django wraps in Value(), or an expression. PormG's db_default takes only an expression, rendered verbatim into the DDL. A literal belongs in default, which PormG renders as the same DEFAULT clause. So the importer translates the option instead of copying it:
| Django | PormG | Why |
|---|---|---|
db_default=0, db_default=False, db_default=Value(0) | default=0, default=false | A literal is a value. default renders the same DEFAULT 0. |
db_default="Retired" | default="Retired" | A Python string is text: Django's DEFAULT 'Retired'. |
db_default="CURRENT_DATE" on a text field | default="CURRENT_DATE" | Still text. Read as SQL it would be today's date. |
db_default="CURRENT_DATE" on a DateField or DateTimeField | no default | The text is not a date, so the field refuses it, and a # PormG: comment says so. For the function, Django's spelling is Now(). |
db_default=Now() on a DateTimeField | db_default="CURRENT_TIMESTAMP" | One of the two expressions every engine renders. |
db_default=Now() on a DateField | db_default="CURRENT_DATE" | The date part of Now(). |
db_default=None | no default | DEFAULT NULL is no default at all. |
anything else (Lower("x"), Pi(), Decimal("0.00"), …) | not imported | Reported with a # PormG: comment telling you to declare a value as default, or to pin an SQL expression per engine. The rest of the file imports. |
class Lap(models.Model):
position = models.IntegerField(default=0, db_default=0)
status = models.CharField(max_length=20, db_default="Finished")
timestamp = models.DateTimeField(db_default=Now())position = Models.IntegerField(default=0)
status = Models.CharField(max_length=20, default="Finished")
timestamp = Models.DateTimeField(db_default="CURRENT_TIMESTAMP")When default and db_default differ, db_default wins. PormG takes one default per column, because its default is both rendered into the column and written by PormG's inserts. In Django, default is applied only in Python, while db_default is what the database holds. PormG's migrations compare your models with that database, so the importer keeps db_default and so converges with the column Django built. Django's default is dropped and a # PormG: comment names it:
points = models.IntegerField(default=1, db_default=0)# PormG: field 'points' on 'Result' declares both `default=1` and `db_default=0`; PormG takes one default, so `db_default` was kept and `default` dropped. Django applies its `default` only in Python, so rows PormG writes get the database's value.
points = Models.IntegerField(default=0)A row the Django ORM inserts without points gets 1; one PormG inserts gets 0. The two values match in the common case (default=0, db_default=0), and then nothing is reported. default=timezone.now beside db_default=Now() means the same thing twice and imports as auto_now_add=true with db_default="CURRENT_TIMESTAMP".
Automatic Additions
- Primary Key: If no primary key is defined,
id = Models.IDField()is automatically added. Declaring any fieldprimary_key=Truesuppresses it, as in Django — including a field namediditself, which then keeps its declared type. The addition is never a replacement: a class that declares a field namedidwhich is not the primary key keeps that column, and no implicitidis written over it. Django rejects that shape itself (models.E004), so it can only arrive from amodels.pythat never passedmanage.py check— the model then comes out with no primary key, reported by a@warnand a# PormG:marker, with the consequences in the warning below - AbstractUser: For models inheriting from
AbstractUser, additional fields likedate_joinedare added.idis not one of them:AbstractUserinherits Django's implicitidfrommodels.Modelrather than owning it, so a user model keyed on its own field —enrolment = CharField(primary_key=True)on a legacy table — has that one key and noid, like any other model
primary_key=True is honoured on exactly five types: IDField, CharField, UUIDField, ForeignKey and OneToOneField.
DecimalField and FloatField reject it deliberately (precision-comparison risk) and raise, which stops the import — re-declare that column as a CharField key in Django, or exclude it.
On every other type the importer drops it before building the field (a PormG field refuses a keyword it does not take, #1333), so ref_code = models.IntegerField(primary_key=True) imports as a plain IntegerField and the model ends up with no primary key at all.
The importer does not paper over that with a synthetic id: the Django table has no such column, and naming one would break every query that expands the field list. It emits a # PormG: marker above the model and a warning naming the field, so the gap is visible in the artifact.
Fix it — do not ship it. A model with no primary key is not merely limited:
- a
ManyToManyFieldpointing at it makes the whole generated file fail to load (ModelDefinitionError: … requires the target model to define a single primary key), taking every other model in the file with it; - a
ForeignKeypointing at it loads and is silently wrong — the key is rendered aspk_field="id", naming a column the table does not have; save()and the migration planner have nothing to key on.
Re-declare the key by hand, matching the real column: an integer key as Models.IDField(generated = false), a text key as Models.CharField(primary_key=true, …) or Models.UUIDField(primary_key=true).
The importer deliberately matches Django's schema conventions, not PormG's: it auto-adds an implicit id primary key and appends _id to foreign-key columns (category → category_id). Native PormG models do neither — you declare the IDField and FK columns are verbatim. The importer matches Django because Django owns that schema and PormG reads it (ETL); it does not run migrations for imported models. See Schema Conventions for the full native contract and this asymmetry.
Limitations and Considerations
What survives the import
| Imported | Fields (including definitions wrapped across lines; Django's BigAutoField maps to IDField, an exact match), ForeignKey / OneToOneField / ManyToManyField — including "self", "<app_label>.<Class>" and settings.AUTH_USER_MODEL targets, Meta.db_table, Meta.unique_together, Meta.constraints, Meta.indexes, Meta.index_together (the last three: see the whitelists above), abstract-base inheritance, AbstractUser auth columns, TextChoices / IntegerChoices enumerations |
| Imported, but degraded and annotated | An AutoField or SmallAutoField — imported as IDField, because IDField is PormG's only integer key type (see the note under Supported Django Fields); the key is faithful, the declared width is not. A model whose base lives in another file — its own fields only. A relation whose target is not in this import — the column survives as a BigIntegerField, the relation does not (a ManyToManyField has no column, so it is dropped); strict_relations = true raises instead. A Meta.db_table that is computed rather than a plain string literal — ignored, name derived from the class. A db_table on an abstract base — not inherited by its children. A unique_together that is a name rather than a literal, or names a field that did not import. A field whose enum this file cannot see — the column survives, the enumeration does not. A field whose choices and/or default the field type rejects at construction — including a lone default on a field with no choices at all, such as one longer than max_length — the column survives without them. A default the importer cannot read as one value (uuid.uuid4, uuid.uuid4() and any other call, 'a' + 'b', an f-string, a raw or triple-quoted literal) — kept verbatim as text, so the stored default is that source text rather than what it denotes (see String defaults). A choices entry whose value or label is not one literal — the entry is kept, carrying that source text, and reported (see Choices). A primary_key=True on a field type PormG cannot key on — the column survives, the key does not, and no id is substituted; the model is then unusable by relations until you re-declare the key (see the warning above). A class-declared field named id that is not the primary key — the declared column is kept and Django's implicit id is not substituted over it, so the model has no key until you declare one. A field named id whose type autofields_ignore dropped — the column is gone as you asked, and no implicit id is written under its name either, because dropping a column and replacing it with a BIGINT auto-increment key are two different requests. Two declarations that write one column under different field names — owner = ForeignKey(…) and owner_id = IntegerField() both name the column owner_id, because Django appends _id to a relation's column. Both are named with their source lines, and the report says which one the file actually emits: the later declaration normally, neither if the later one's type has no PormG counterpart, and the earlier one if the later one's type is in autofields_ignore. Reported whether the two sit in one class body or arrive through an abstract base, and the report names which body each one is in — Django rejects both arrangements (models.E007), because an abstract base's fields are copied into the child. A child re-declaring the same field name it inherited is not this: that is the merge working as intended, and it stays silent. The same name declared twice in one body is reported, but as what it is — Python rebinds, so Django sees one field and manage.py check passes; the advice is to delete the declaration you did not mean, not to rename one. A column option on an ArrayField's element (db_index, unique, default, …) — dropped, because Django ignores it there and PormG refuses it; the array column itself imports. A custom field class defined in the import (class DriverCodeField(models.CharField)) — imported as its Django base type with the call's options; its Python-side behaviour is not replicated (see the note under Supported Django Fields). An option the PormG field does not take (max_length on an EmailField, to_field on a ForeignKey, …) — dropped, the field imports without it (see Parameter Conversion) |
| Reported and skipped | Meta.ordering and every other option with no PormG equivalent; a UniqueConstraint or an Index PormG cannot express; multi-table inheritance; proxy models; a field-shaped call the importer cannot read (tags = HStoreField()), including a custom field class whose chain overrides db_type or a sibling hook, or inherits a base the importer cannot see; a field whose Django type PormG does not implement (FilePathField, SmallIntegerField, …), or an ArrayField whose element PormG's ArrayField cannot hold (ArrayField(models.JSONField())) — the field, its class and its models.py line are named, and every other field, class and app in the call still imports, with strict_fields = true raising instead. Both leave the live column addressable by nothing in the model, so makemigrations reads it as drift |
| Not supported | Model methods, managers, signals and validators are Python and have no PormG counterpart |
Nothing in the middle two rows is dropped in silence: each one produces a @warn at import time and a # PormG: comment in the generated file, naming the class app-qualified when a label is known (see Meta Options). That is the contract this importer holds itself to — if something did not survive, the artifact says so.
API Naming Differences from Django
PormG is inspired by Django, not a port — where an API is genuinely different, it also gets its own name rather than borrowing Django's. The one you will notice first when migrating:
| Django | PormG | Why it's not just a rename |
|---|---|---|
Model.objects.bulk_create(objs, batch_size=…, ignore_conflicts=…) | bulk_insert(query, df; chunk_size=…, on_conflict=…) | PormG's bulk API is DataFrame-first: it inserts rows from a DataFrame (with optional "df_col" => "model_field" mapping) instead of a list of model instances, and it does not materialize/return created instances. Conflict handling is the explicit on_conflict= kwarg (:nothing, or (action = :update, target = […], set = […]) for upserts) rather than Django's ignore_conflicts/update_conflicts booleans. |
bulk_insert sits beside its siblings bulk_update and bulk_copy — see Bulk Operations for the full API.
Datetime Interoperability Contract
When PormG writes into tables that are also managed by Django, timezone semantics need to be explicit.
DateTimeFielddefaults are stored asUnion{ZonedDateTime, DateTime, Nothing}.- Every
DateTimeFieldvalue is canonicalized to one UTC ISO-8601 string (yyyy-mm-ddTHH:MM:SS.sss+00:00) on write, bind, and filter (issue #79) — the same convention as DjangoUSE_TZ=True. Equality/range filters return the same rows on PostgreSQL, SQLite and MySQL regardless of the input's spelling. - A user-supplied naive Julia
DateTimeis serialized asUTCthrough the formatter path. - A
ZonedDateTimepreserves the intended instant (converted to UTC for storage) and is the recommended type when the source system has a real business timezone. - Internal
auto_nowandauto_now_addtimestamps are generated insettings.time_zoneand then canonicalized to UTC on serialization — the same instant, stored in the UTC spelling. - If your Django app uses
USE_TZ=TrueandTIME_ZONE = "America/Sao_Paulo", passing a plainDateTime(2026, 3, 13, 9, 0)from Julia does not mean "09:00 Sao Paulo"; it means "09:00 UTC". UseZonedDateTime(DateTime(2026, 3, 13, 9, 0), tz"America/Sao_Paulo")when the civil timezone matters.
This will generate Julia models compatible with PormG that maintain the same structure and relationships as your original Django models.
Using Converted Models with @import_models
After converting your Django models to PormG format, load them in a script or the REPL with the @import_models macro:
using PormG, LibPQ
# The configuration first: registering models never loads it for you
PormG.Configuration.load("db"; env = "dev")
# Load the converted models
PormG.@import_models "db/models.jl" models
import .models as M
# Now you can use queries like:
# M.Product.objects.filter("price__@gt" => 100) |> DataFrameThis approach provides:
- Automatic registration of all converted models, with no manual
set_models()call - Hot-reloading during interactive development (with Revise.jl loaded)
In a package, include the models file in the module body and bind it from __init__ with PormG.Models.set_models(models, "db"; source = MODELS_FILE), as in The Bootstrap Sequence.
For more details on using models in your application, see Defining Models in PormG.