What's up Python? Astral's new service, pandas 3 and a new ORM...
Jan 2026
Summary
Astral publishes UVX.sh, a service that lets anybody install any Python CLI tool published on Pypi without having to deal with Python. Or to even know it’s Python.
Pandas 3 is out. It breaks a few things, but the new API avoids many footguns like unexpected mutations and untyped strings.
A new Python ORM named Oxyde has been released, and for once, it comes with really interesting design choices: async by default yet Django ergonomics and still good perfs, typing validation with pydantics, integrated migrations and n+1 safety nets.
And of course, moar stuff.
UVX.sh
If we didn’t have a big announcement from Astral, would it be a month worth living?
They, rather quietly, published UVX.sh, a website that lets you curl install any Python tool. Of course, it uses uvx under the hood (and the secret sauce is not even that complicated).
This means you can install things like the excellent video download yt-dlp by typing:
curl -LsSf uvx.sh/yt-dlp/install.sh | shOn Unix.
And:
powershell -ExecutionPolicy ByPass -c "irm https://uvx.sh/yt-dlp/install.ps1 | iex"On Windows.
It’s simple and wicked fast. But on top of it, it requires no knowledge of Python whatsoever, something that has always made installing Python tools a barrier in the past.
Now, because Python has one of the best supports for foreign language interfaces, PyPI is full of tools that are actually not written in Python.
You can surprisingly install nodejs, redis, just or ripgrep.
At this point, this makes the whole thing look more and more like homebrew, but faster, and with fewer footguns.
You can do wild things with it:
Fully bootstrap your project. Because, well, you can install
uv, and then run it right after.Run any code file or full repo from anywhere on the web, because well, uv can just do that.
One shot run any cli tool that uses an executable you like available on PyPI without having to wonder where it comes from and where it’s going. Because, well, uvx can do that.
You don’t need to care about your Python setup. The people you give the command to don’t need either.
Wanna run a full templated project? One shot copier. Wanna convert a file to markdown? One shot markitdown. Want to have a great shell to explore a rest API? One shot httpie. Wanna share this jupyter notebook with your non-python expert colleagues? One shot juv.
This is an idea I played with for some time and talked about on Bluesky before. But the difference between talking and doing is me and Astral.
Pandas 3 is out
A big breaking pandas update is here, and it’s about time.
pandas, the most popular data exploration library, has seen its lunch slowly eaten by alternatives with better perfs and a better API such as polars.
Time to react.
There are many changes, but the most important are:
If you store a string in a dataframe, it’s now a string type by default. Believe it or not, but up until today, pandas assigned it the type of a generic
object. This was annoying and slow. Now it’s just faster and more congruent:
import pandas as pd
ser = pd.Series(["a", "b"])
0 a
1 b
dtype: strCopy-on-write behavior is now consistent. Indexing ALWAYS returns a copy of the previous series or dataframe. This means this used to modify the original dataframe:
>>> df = pd.DataFrame({"A": [1,2], "B": [3,4]})
>>> s = df["A"]
>>> s.iloc[0] = 100
>>> print(df)
A B
0 100 3
1 2 4But not in pandas 3. Now this prints:
A B
0 1 3
1 2 4Because the series is a copy. This avoids the problem where, sometimes, indexing returned a copy, and sometimes a reference. Now it’s always a copy, and you know what you get.
Want to modify the original dataframe? Index it directly:
>>> df.loc[0, "A"] = 100
>>> df
A B
0 100 3
1 2 4The goal is to encourage a more functional style in the long run. But for now the imperative style is still the most convenient for cell operations.
However, for column-based operation, the new
colobject is great for functional style:
Before:
>>> df = pd.DataFrame({'a': [1, 1, 2], 'b': [4, 5, 6]})
>>> df.assign(c=lambda df: df['a'] + df['b'])
a b c
0 1 4 5
1 1 5 6
2 2 6 8With col:
>>> from pandas import col
>>> df.assign(c=col('a') + col('b'))
a b c
0 1 4 5
1 1 5 6
2 2 6 8This allow to use a functional style instead of an imperative style without needing a lambda in the way.
Of course, all that stuff will break a lot of pandas code, so don’t upgrade old projects on a whim. But if you start a new project and you want to use pandas instead of polars, then this will make your pandas code faster, more robust, and encourage better patterns.
There is a new ORM in town
There are a lot of ORMs in the Python world, but practically, I use 3:
Django’s ORM, when I can, because it’s the most convenient. If I want perfs, I write SQL manually.
SQLAlchemy when I want an ORM, but I’m not on Django. Or when I want an abstraction without ORM.
Peewee, when I need a quick DB thing and I don’t need anything fancy or good integration.
Each of them has their problems:
Their async story is not great.
Django’s ORM is framework-dependent. Also, it has the worst perfs of all. Still my favorite because the ergonomics are amazing.
SQLAlchemy is really hard to use correctly. Most SQLA projects out there commit some crime or another while using it. And it’s damn verbose.
Peewee has super limited integration with other tools, and not much benefit compared to the others except being lightweight. A good selling point, mind you, I’m not denying it.
And I’ve been looking at every new ORM coming around the corner, and none appealed to me. They never had this thing that led me to think that the cost of trying it out would be offset by what they had to offer.
Until oxyde ORM just came out:
It’s async by default.
It supports type hints quite naturally.
It has Django’s ORM’s ergonomics, but without annoying stuff like magical id, table inheritance shotguns and n+1 honeypots.
It’s faster (per their benchmark) than all the alternatives.
It’s using Pydantic models like SQLModel, but without the boilerplate.
Good support for transaction, raw sql, and serialization.
It comes with built-in
explain()method.It has a decent API for DB-specific settings, like a
sqlite_journal_mode="WAL"knob and a configurable pool setting class that you can easily use.It has separate pre/post_create/delete hooks.
It does migrations out of the box.
I really like what I’m seeing.
I haven’t used it in a real project yet, and it will take some time before I do, but I played with it using about 100,000 objects in a CRUD demo using movies and actors, and it’s been nice. This is nice to read:
from __future__ import annotations
from typing import Optional
from oxyde import OxydeModel, Field
class Genre(OxydeModel):
"""Movie genre."""
id: Optional[int] = Field(default=None, db_pk=True)
name: str = Field(db_unique=True)
class Meta:
is_table = True
table_name = "genres"
class Director(OxydeModel):
"""Movie director."""
id: Optional[int] = Field(default=None, db_pk=True)
name: str = Field(db_index=True)
birth_year: Optional[int] = Field(default=None)
country: Optional[str] = Field(default=None)
class Meta:
is_table = True
table_name = "directors"
class Actor(OxydeModel):
"""Movie actor."""
id: Optional[int] = Field(default=None, db_pk=True)
name: str = Field(db_index=True)
birth_year: Optional[int] = Field(default=None)
country: Optional[str] = Field(default=None)
class Meta:
is_table = True
table_name = "actors"
class Movie(OxydeModel):
"""Movie entry."""
id: Optional[int] = Field(default=None, db_pk=True)
title: str = Field(db_index=True)
year: int = Field(db_index=True)
rating: Optional[float] = Field(default=None)
duration_minutes: Optional[int] = Field(default=None)
synopsis: Optional[str] = Field(default=None)
# Optional at Python level to allow director_id=N syntax, but NOT NULL in DB
director: Optional[Director] = Field(default=None, db_nullable=False, db_on_delete="RESTRICT")
genre: Optional[Genre] = Field(default=None, db_on_delete="SET NULL")
class Meta:
is_table = True
table_name = "movies"
class MovieActor(OxydeModel):
"""Many-to-many relationship between movies and actors."""
id: Optional[int] = Field(default=None, db_pk=True)
movie_id: int = Field(db_fk="movies.id", db_on_delete="CASCADE", db_index=True)
actor_id: int = Field(db_fk="actors.id", db_on_delete="CASCADE", db_index=True)
class Meta:
is_table = True
table_name = "movie_actors"
And it’s nice to use:
>>> await Movie.objects.first() # you can't n+1 yourself to death
Movie(
id=1,
title='Cross-Group Impactful Conglomeration',
year=1979,
rating=2.6,
duration_minutes=87,
synopsis='Show couple military indeed subject vote grow. Company however quite especially culture relationship.',
director=None,
genre=None,
director_id=2332,
genre_id=27
)
>>> await Movie.objects.join("director").first()
Movie(
id=1,
title='Cross-Group Impactful Conglomeration',
year=1979,
rating=2.6,
duration_minutes=87,
synopsis='Show couple military indeed subject vote grow. Company however quite especially culture relationship.',
director=Director(id=2332, name='Ashley Morton', birth_year=1969, country='UK'),
genre=None,
director_id=2332,
genre_id=27
)
>>> (await Movie.objects.join("director").first()).director.model_dump()
{'id': 2332, 'name': 'Ashley Morton', 'birth_year': 1969, 'country': 'UK'}
>>> await Movie.objects.filter(director__name__contains="daniel").count()
1046
>>> Director.objects.create(name="Foo", birth_year=1990, country=1)
<coroutine object QueryManager.create at 0x752a14efa640>
>>> await Director.objects.create(name="Foo", birth_year=1990, country=1)
Traceback (most recent call last):
...
ValidationError: 1 validation error for Director
country
Input should be a valid string [type=string_type, input_value=1, input_type=int]
For further information visit https://errors.pydantic.dev/2.12/v/string_type
>>> await Director.objects.create(name="Foo", birth_year=1990, country="uk")
Director(id=5001, name='Foo', birth_year=1990, country='uk')You see, one thing that I positively love about Django’s ORM and hate about SQLA is the session management. Yes, SQLA gives you more granularity to use fully your DB features and the various compromises about isolation levels, rollback strategies and so forth. But in the vast majority of my projects, I don’t want to spend mental energy on that. I just want to have a big transparent connection pool that will automatically make the Pareto decision.
In fact, I’ve been way more often in a situation where I joined a project with a disastrous SQLA session management that caused a ton of problems because the team didn’t understand the subtleties of the topic, than I got into trouble because of Django automatic behavior.
In this shell example, you can see I don’t have to care about connection, session, commit. And I love that.
So Oxyde seems not only to be a good compromise between the 3 ORMs I like, but more performant, with typing and good async integration to be used in FastAPI.
One of the consequences of this is that you can use modern Python features very naturally, like task groups:
async with asyncio.TaskGroup() as tg:
user_task = tg.create_task(User.objects.get(id=1))
posts_task = tg.create_task(Post.objects.filter(author_id=1).all())
user = user_task.result()
posts = posts_task.result()This means making parallel I/O very clean and simple. I/O is a low-hanging fruit for perfs in web projects, and this part has always been harder than necessary in Python.
ty can happily check the codebase, because it’s all regular typing. And since your models are just Pydantic under the hood, you can use any Pydantic validator and serializer your heart desires.
What’s not to love?
Well, the fact that it’s version 0.3 and that with db stuff, you really, REALLY want mature tech that will be maintained in 10 years. So I’m going to keep exploring it, but I’m not going to commit to that tech for important things.
Of course, this also means the doc needs some love. What’s more, some design decisions are not to my liking:
An implicitly imported Python config file is one of the worst warts of Django. Copying that is a terrible idea.
Detecting automatically models.py kinda makes sense in a framework, but not for a generic lib.
There is no sync API. I like that async is the default, and having to maintain 2 API is a pain. I get it. But typing await everywhere in a script or in the shell is not a fun experience. I wish I could do
SyncUser = User.get_sync_model()for those use cases.
Still, I’m happy it exists, it’s a tremendous new project. Let’s see where it’s going.
And as per tradition now, moar
It’s now easier than ever to distribute Go binaries on pypi.. Gonna mix well with uvx.sh.
buildis out in 1.4, adding straighforward dump of package metadata.Core dev Savannah Ostrowski releases debugwand: a zero-preparation remote debugger for Python applications running in Kubernetes clusters or Docker containers
Anthropic (the company behind Claude code) is investing $1.5 million in the PSF, focused on security.
Python dev survey for 2026 is out. It takes 20 minutes, and is super interesting each year. Please fill it out!
The French gov is working on alternatives to American tech. I wrote about why it’s absolutely imperative for our survival before it was cool. The first round of tools uses Django as a backend.
Astral reveals it’s been using AI regularly. Armin Ronacher, Antirez, Linus Torvald, and now them. People who said AI was not a serious coding tool have lost credibility forever in my mind. Of course, I now expect the pivot where said people will argue they never said that, but I won’t waste time engaging with them anymore.
The excellent Django packages website gets a new design. This is just an opportunity for me to talk about this old gold nugget to find django related libraries. It’s using
tailwindandhtmx, two technologies that again many people said were not for serious use. I’m sensing a recurring theme.The great Django CRM engine Wagtail gets a new version that includes a very welcome autosave feature.
And the usual updates for Python: 3.15 alpha, 3.14.13 and 3.13.12.

Thank you for a great summary - always a pleasure to read
I think, there is an issue with your first Pandas example, the dtype is the old "object" and not "str".