高校生が個人サービスを作る中で、自作SQLite系の実装を作りつつ、結局メインサービスや重要な部分ではPostgreSQL 17と18を使っている話です。Pythonからはasyncpgを直接触らず、自分用のラッパーを挟んで使っています。ラッパー本体は公開できないので、この記事では考え方だけを書きます。
はじめに
自分は高校生です。
普段は個人でサービスを作っています。
その中で、dictsqlite、nanasqlite、nyansqliteみたいな、自分用のSQLite系ライブラリも作ってきました。
SQLiteは好きです。
ファイル1つで動くし、開発中に扱いやすいし、自分でラッパーを書くとDBの仕組みも少し分かります。
ただ、実際にサービスのメイン部分や重要なデータを持つ部分では、PostgreSQLを使っています。
今はPostgreSQL 17と18を使っています。
「自分でDBっぽいものを作っているのに、結局PostgreSQLを使うのか」と言われると、はい、使います。
むしろ作ったからこそ、任せた方がいいところが見えてきました。
自作SQLite系でやりたかったこと
自分用のSQLite系ライブラリを作った理由は、最初はかなり単純でした。
- Pythonから楽に保存したい
- 辞書っぽく扱いたい
- 小さいデータをすぐ永続化したい
- サービスごとに同じようなDB処理を書きたくない
- ORMほど重くないものがほしい
個人開発だと、最初から大きいDB設計をするより、まず動くものを作りたいことが多いです。
SQLiteはそこに合っています。
Botの設定、ちょっとしたキャッシュ、ローカルの検証データ、プロトタイプの保存先。
こういう用途なら、自作ラッパーでもかなり戦えます。
でも、使っているうちに「ここから先はPostgreSQLに寄せた方がいいな」と思う場面が増えました。
PostgreSQLに任せているところ
自分のサービスでは、メインサービスや重要な部分はPostgreSQLを使っています。
理由は分かりやすいです。
- 複数ユーザーが同時に触る
- データを壊したくない
- トランザクションをちゃんと使いたい
- インデックスや実行計画を見たい
- JSONBを使いたい
- 本番環境で運用しやすくしたい
- 将来データが増えても逃げ道を残したい
SQLiteが悪いという話ではありません。
むしろSQLiteはかなり強いです。
ただ、自分の中では役割が分かれました。
| 用途 | 使うもの |
|---|---|
| ローカルの小さい保存 | 自作SQLite系ライブラリ |
| プロトタイプ | SQLiteまたは自作ラッパー |
| サービスの中心データ | PostgreSQL |
| 消えると困るデータ | PostgreSQL |
| 複数処理が同時に触るデータ | PostgreSQL |
自作したものを捨てたわけではありません。
使う場所を分けただけです。
Pythonからはasyncpgをラップしている
PythonからPostgreSQLを使うときは、asyncpgを使っています。
ただ、サービス側から毎回asyncpgを直接触るのは少し面倒です。
なので、自分用のラッパーを作っています。
ラッパー本体は公開できません。
でも、やっていることはだいたいこんな感じです。
- 接続プールを作る
-
async withで初期化とクローズを扱う - トランザクションをコンテキストマネージャーで扱う
- JSONBをPythonの
dictとして扱いやすくする - Pydanticモデルや辞書をINSERTしやすくする
-
insert、update、delete、upsert、searchをよく使う形で包む - 部分一致、前方一致、後方一致などの検索モードを明示する
- 集計処理を少し書きやすくする
- テーブル名やカラム名を最低限チェックする
- 接続エラー時にリトライする
生SQLを全部隠したいわけではありません。
PostgreSQLを使うなら、SQLはちゃんと書けた方がいいと思っています。
ただ、サービスの中で毎回同じようなCRUDを書くのはだるいです。
そこはラッパーに寄せています。
ラッパーを作ってよかったこと
一番よかったのは、アプリ側のコードが読みやすくなったことです。
たとえば、ユーザー設定を保存する、イベントログを入れる、特定条件で検索する。
こういう処理はサービス内に何度も出てきます。
毎回こういうことを考えるのは面倒です。
- プールから接続を取る
- SQLのプレースホルダを書く
- JSONBをどう渡すか考える
- トランザクション内かどうかを気にする
- 例外をどう包むか決める
- UPSERTの
ON CONFLICTを書く
ラッパーを挟むと、普段の処理はかなり短くなります。
一方で、重要なクエリはSQLを見ます。
ここは隠しすぎないようにしています。
DBは便利な箱ではなく、サービスのかなり中心にある部品です。
特にPostgreSQLを使うなら、インデックス、制約、トランザクション、実行計画を見ないと怖いです。
PostgreSQL 17と18を使って感じたこと
PostgreSQL 17と18を使っていて思うのは、DB側に任せられることが多いということです。
自分でSQLite系ライブラリを作ると、どうしてもアプリ側で頑張りたくなります。
でもPostgreSQLでは、DB側の機能を使った方が自然なことが多いです。
たとえば、次のようなものです。
- 一意制約
- 外部キー
- JSONB
- UPSERT
- トランザクション
- インデックス
- 生成列
- 実行計画の確認
アプリ側で頑張って整合性を守るより、DBに制約として持たせた方が安心できる場面があります。
これは、個人開発でもかなり大事です。
個人開発は人が少ないので、気合いで運用しがちです。
でも、人が少ないからこそ、DB側に守ってもらえるものは守ってもらった方がいいです。
PostgreSQL 18で気になっている機能
PostgreSQL 18では、気になっている機能がいくつかあります。
この記事を書いている2026年6月1日時点では、PostgreSQL 18系が最新のメジャーバージョンです。公式のリリースノートでは、2026年5月14日にPostgreSQL 18.4、17.10、16.14、15.18、14.23が公開されています。
公式リリースノートでは、非同期I/O、B-treeインデックスのskip scan、uuidv7()、仮想生成列、pg_upgrade時の統計情報保持、OAuth認証などが紹介されています。
自分のサービス目線で特に見たいのは、uuidv7()とskip scanです。
uuidv7()は個人サービスでも普通にうれしい
個人サービスでも、IDにUUIDを使うことはあります。
今まではアプリ側でUUIDを作ることが多かったです。
でもPostgreSQL 18では、DB側でuuidv7()を使えます。
create table events (
id uuid primary key default uuidv7(),
user_id uuid not null,
event_type text not null,
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
イベントログや履歴系のテーブルでは、時間順に並びやすいIDは相性がよさそうです。
もちろん、何でもDB側生成にすればいいわけではありません。
アプリ側で先にIDが必要なこともあります。
でも、DBにINSERTしてからIDが分かればいいテーブルなら、default uuidv7()はかなり使いやすそうです。
skip scanはインデックス追加の前に見たい
PostgreSQL 18のskip scanも気になっています。
複合B-treeインデックスは、先頭列に条件がないと使いにくい、という話をよく見ます。
PostgreSQL 18では、後ろの列の条件でも複合インデックスを使える場面が増えます。
もちろん、必ず使われるわけではありません。データ分布、統計情報、条件の選択度によって実行計画は変わります。
たとえば、こういうインデックスがあるとします。
create index idx_events_user_created_at on events (user_id, created_at);
user_idで絞るクエリには分かりやすく効きます。
select *
from events
where user_id = $1
order by created_at desc
limit 50;
でも、運用中に「直近1時間のイベントだけ見たい」みたいなクエリが出てくることがあります。
select *
from events
where created_at >= now() - interval '1 hour';
こういうとき、すぐにcreated_at単体のインデックスを足す前に、PostgreSQL 18で実行計画を見たいです。
explain (analyze, buffers)
select *
from events
where created_at >= now() - interval '1 hour';
実行計画を見てからインデックスを足す。
これは当たり前に見えて、個人開発だとつい後回しにしがちです。
PostgreSQL 18では、この確認が少し楽しくなりそうです。
仮想生成列もラッパーと相性がよさそう
PostgreSQL 18では、仮想生成列も使えます。
たとえば、メールアドレスのドメイン部分をよく使うなら、こういう形にできます。
create table users (
id uuid primary key default uuidv7(),
email text not null,
email_domain text generated always as (split_part(email, '@', 2)) virtual
);
アプリ側で毎回split('@')するより、DB側に定義として置いた方が読みやすい場面があります。
自分のラッパーでは、辞書やPydanticモデルをDBに流し込みやすくしています。
そのとき、アプリ側で持たなくていい値はDB側の生成列に寄せると、モデルも少し軽くできます。
ただし、仮想生成列は読み取り時に計算されます。
よく読む値なら保存生成列や普通のカラムの方が合うこともあります。
ここは実データで見たいです。
高校生だからこそ雑に試せる
高校生で個人開発をしていると、会社の本番DBほど大きな責任はありません。
これは弱さでもありますが、強さでもあります。
新しいPostgreSQLを触る。
ラッパーを作る。
自作SQLite系と比べる。
失敗したら設計を変える。
こういう試行錯誤をかなり速く回せます。
もちろん、本番データを雑に扱っていいという意味ではありません。
バックアップは取るし、重要なデータでは慎重にやります。
でも、学ぶ速度は個人開発の方が速いことがあります。
特にDBは、記事やドキュメントを読むだけだと分かった気になりやすいです。
実際にサービスに入れて、詰まって、直した方が覚えます。
自作したからPostgreSQLのありがたさが分かった
自分でSQLite系のラッパーを作ると、DBまわりで考えることが増えます。
- 型をどう扱うか
- JSONをどう保存するか
- 接続をどう管理するか
- エラーをどう扱うか
- 検索条件をどう表現するか
- 更新と挿入をどうまとめるか
- 危ないSQLをどう避けるか
これを自分で考えたあとにPostgreSQLを使うと、ありがたさがかなり分かります。
PostgreSQLは、ただデータを保存する場所ではありません。
制約、トランザクション、インデックス、JSONB、実行計画、拡張機能まで含めて、アプリの土台になります。
自作ライブラリは、自分の手に馴染む道具です。
PostgreSQLは、サービスをちゃんと支える土台です。
今の自分には、その両方が必要です。
まとめ
高校生の個人開発でも、PostgreSQLは普通に必要です。
自分はdictsqlite、nanasqlite、nyansqliteのような自作SQLite系ライブラリを作ってきました。
それでも、メインサービスや重要な部分にはPostgreSQL 17と18を使っています。
Pythonからはasyncpgを自分用にラップして、接続プール、トランザクション、JSONB、Pydantic、UPSERT、検索、集計などを扱いやすくしています。
ラッパー本体は公開できませんが、作ってよかったです。
PostgreSQL 18では、uuidv7()、skip scan、仮想生成列あたりを特に試したいです。
自分のサービスに入れるなら、ただ「新機能だから使う」ではなく、既存のID設計、インデックス、読み取り頻度、運用手順と合わせて見ます。
自分で小さいDBまわりを作ってみる。
そのうえで、重要なところはPostgreSQLに任せる。
今の自分には、このバランスが一番しっくり来ています。