Skip to content

Code Structure and Multiple Files

Let's stop for a second to think about how to structure the code, particularly in large projects with multiple files.

Circular Imports

The class Hero has a reference to the class Team internally.

But the class Team also has a reference to the class Hero.

So, if those two classes where in separate files and you tried to import the classes in each other's file directly, it would result in a circular import. πŸ”„

And Python will not be able to handle it and will throw an error. 🚨

But we actually want to mean that circular reference, because in our code, we would be able to do crazy things like:

hero.team.heroes[0].team.heroes[1].team.heroes[2].name

And that circular reference is what we are expressing with these relationship attributes, that:

  • A hero can have a team
    • That team can have a list of heroes
      • Each of those heroes can have a team
        • ...and so on.

Let's see different strategies to structure the code accounting for this.

Single Module for Models

This is the simplest way. ✨

In this solution we are still using multiple files, for the models, for the database, and for the app.

And we could have any other files necessary.

But in this first case, all the models would live in a single file.

The file structure of the project could be:

.
β”œβ”€β”€ project
    β”œβ”€β”€ __init__.py
    β”œβ”€β”€ app.py
    β”œβ”€β”€ database.py
    └── models.py

We have 3 Python modules (or files):

  • app
  • database
  • models

And we also have an empty __init__.py file to make this project a "Python package" (a collection of Python modules). This way we can use relative imports in the app.py file/module, like:

from .models import Hero, Team
from .database import engine

We can use these relative imports because, for example, in the file app.py (the app module) Python knows that it is part of our Python package because it is in the same directory as the file __init__.py. And all the Python files on the same directory are part of the same Python package too.

Models File

You could put all the database Models in a single Python module (a single Python file), for example models.py:

from typing import List, Optional

from sqlmodel import Field, Relationship, SQLModel


class Team(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str
    headquarters: str

    heroes: List["Hero"] = Relationship(back_populates="team")


class Hero(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str
    secret_name: str
    age: Optional[int] = None

    team_id: Optional[int] = Field(default=None, foreign_key="team.id")
    team: Optional[Team] = Relationship(back_populates="heroes")

This way, you wouldn't have to deal with circular imports for other models.

And then you could import the models from this file/module in any other file/module in your application.

Database File

Then you could put the code creating the engine and the function to create all the tables (if you are not using migrations) in another file database.py:

from sqlmodel import SQLModel, create_engine

sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)

This file would also be imported by your application code, to use the shared engine and to get and call the function create_db_and_tables().

Application File

Finally, you could put the code to create the app in another file app.py:

from sqlmodel import Session

from .database import create_db_and_tables, engine
from .models import Hero, Team


def create_heroes():
    with Session(engine) as session:
        team_z_force = Team(name="Z-Force", headquarters="Sister Margaret’s Bar")

        hero_deadpond = Hero(
            name="Deadpond", secret_name="Dive Wilson", team=team_z_force
        )
        session.add(hero_deadpond)
        session.commit()

        session.refresh(hero_deadpond)

        print("Created hero:", hero_deadpond)
        print("Hero's team:", hero_deadpond.team)


def main():
    create_db_and_tables()
    create_heroes()


if __name__ == "__main__":
    main()

Here we import the models, the engine, and the function to create all the tables and then we can use them all internally.

Order Matters

Remember that Order Matters when calling SQLModel.metadata.create_all()?

The point of that section in the docs is that you have to import the module that has the models before calling SQLModel.metadata.create_all().

We are doing that here, we import the models in app.py and after that we create the database and tables, so we are fine and everything works correctly. πŸ‘Œ

Run It in the Command Line

Because now this is a larger project with a Python package and not a single Python file, we cannot call it just passing a single file name as we did before with:

$ python app.py

Now we have to tell Python that we want it to execute a module that is part of a package:

$ python -m project.app

The -m is to tell Python to call a module. And the next thing we pass is a string with project.app, that is the same format we would use in an import:

import project.app

Then Python will execute that module inside of that package, and because Python is executing it directly, the same trick with the main block that we have in app.py will still work:

if __name__ == '__main__':
    main()

So, the output would be:

$ python -m project.app

Created hero: id=1 secret_name='Dive Wilson' team_id=1 name='Deadpond' age=None
Hero's team: name='Z-Force' headquarters='Sister Margaret’s Bar' id=1

Make Circular Imports Work

Let's say that for some reason you hate the idea of having all the database models together in a single file, and you really want to have separate files a hero_model.py file and a team_model.py file.

You can also do it. 😎 There's a couple of things to keep in mind. πŸ€“

Warning

This is a bit more advanced.

If the solution above already worked for you, that might be enough for you, and you can continue in the next chapter. πŸ€“

Let's assume that now the file structure is:

.
β”œβ”€β”€ project
    β”œβ”€β”€ __init__.py
    β”œβ”€β”€ app.py
    β”œβ”€β”€ database.py
    β”œβ”€β”€ hero_model.py
    └── team_model.py

Circular Imports and Type Annotations

The problem with circular imports is that Python can't resolve them at runtime.

but when using Python type annotations it's very common to need to declare the type of some variables with classes imported from other files.

And the files with those classes might also need to import more things from the first files.

And this ends up requiring the same circular imports that are not supported in Python at runtime.

Type Annotations and Runtime

But these type annotations we want to declare are not needed at runtime.

In fact, remember that we used List["Hero"], with a "Hero" in a string?

For Python, at runtime, that is just a string.

So, if we could add the type annotations we need using the string versions, Python wouldn't have a problem.

But if we just put strings in the type annotations, without importing anything, the editor wouldn't know what we mean, and wouldn't be able to help us with autocompletion and inline errors.

So, if there was a way to "import" some things that act as "imported" only while editing the code but not at runtime, that would solve it... And it exists! Exactly that. πŸŽ‰

Import Only While Editing with TYPE_CHECKING

To solve it, there's a special trick with a special variable TYPE_CHECKING in the typing module.

It has a value of True for editors and tools that analyze the code with the type annotations.

But when Python is executing, its value is False.

So, we can us it in an if block and import things inside the if block. And they will be "imported" only for editors, but not at runtime.

Hero Model File

Using that trick of TYPE_CHECKING we can "import" the Team in hero_model.py:

from typing import TYPE_CHECKING, Optional

from sqlmodel import Field, Relationship, SQLModel

if TYPE_CHECKING:
    from .team_model import Team


class Hero(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str
    secret_name: str
    age: Optional[int] = None

    team_id: Optional[int] = Field(default=None, foreign_key="team.id")
    team: Optional["Team"] = Relationship(back_populates="heroes")

Have in mind that now we have to put the annotation of Team as a string: "Team", so that Python doesn't have errors at runtime.

Team Model File

We use the same trick in the team_model.py file:

from typing import TYPE_CHECKING, List, Optional

from sqlmodel import Field, Relationship, SQLModel

if TYPE_CHECKING:
    from .hero_model import Hero


class Team(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str
    headquarters: str

    heroes: List["Hero"] = Relationship(back_populates="team")

Now we get editor support, autocompletion, inline errors, and SQLModel keeps working. πŸŽ‰

App File

Now, just for completeness, the app.py file would import the models from both modules:

from sqlmodel import Session

from .database import create_db_and_tables, engine
from .hero_model import Hero
from .team_model import Team


def create_heroes():
    with Session(engine) as session:
        team_z_force = Team(name="Z-Force", headquarters="Sister Margaret’s Bar")

        hero_deadpond = Hero(
            name="Deadpond", secret_name="Dive Wilson", team=team_z_force
        )
        session.add(hero_deadpond)
        session.commit()

        session.refresh(hero_deadpond)

        print("Created hero:", hero_deadpond)
        print("Hero's team:", hero_deadpond.team)


def main():
    create_db_and_tables()
    create_heroes()


if __name__ == "__main__":
    main()

And of course, all the tricks with TYPE_CHECKING and type annotations in strings are only needed in the files with circular imports.

As there are no circular imports with app.py, we can just use normal imports and use the classes as normally here.

And running that achieves the same result as before:

$ python -m project.app

Created hero: id=1 age=None name='Deadpond' secret_name='Dive Wilson' team_id=1
Hero's team: id=1 name='Z-Force' headquarters='Sister Margaret’s Bar'

Recap

For the simplest cases (for most of the cases) you can just keep all the models in a single file, and structure the rest of the application (including setting up the engine) in as many files as you want.

And for the complex cases that really need separating all the models in different files, you can use the TYPE_CHECKING to make it all work and still have the best developer experience with the best editor support. ✨