Getting started¶
Installation¶
Or with uv:
uv add synalog # add it to a uv-managed project
uv pip install synalog # or install into the current virtualenv
Requires Python 3.10+. Wheels are published for Linux (x86_64, aarch64, armv7, s390x, ppc64le; glibc and musl), Windows (x64, x86, aarch64) and macOS (x86_64, aarch64).
duckdb (the default engine), sqlite and PostgreSQL work out of the box: synalog depends on duckdb and on psycopg with its bundled libpq, so nothing else is needed on macOS or Linux.
Your first program¶
A Synalog project maps tables (the data), defines concepts (entities and relationships extracted from tables) and writes rules (what you want to know), one predicate per file (see Program structure). You compile a predicate to SQL and run it with any database driver. To start, a single program can hold its own data as facts:
import synalog
source = """
@Engine("duckdb");
Employee(name: "Alice", department: "Engineering", salary: 75000);
Employee(name: "Bob", department: "Marketing", salary: 65000);
Employee(name: "Charlie", department: "Engineering", salary: 80000);
@OrderBy(EngineeringTeam, "name");
EngineeringTeam(name:, salary:) :- Employee(name:, department: "Engineering", salary:);
"""
# 1. Validate the program: returns a list of error messages
errors, warnings = synalog.check(source)
assert errors == []
# 2. Compile a predicate to SQL
sql = synalog.compile(source, "EngineeringTeam")
print(sql)
Output (the DuckDB initialization preamble is omitted here; the complete example below shows the full, unedited output):
WITH t_0_Employee AS (SELECT * FROM (
SELECT
'Alice' AS name,
'Engineering' AS department,
75000 AS salary
UNION ALL
SELECT
'Bob' AS name,
'Marketing' AS department,
65000 AS salary
UNION ALL
SELECT
'Charlie' AS name,
'Engineering' AS department,
80000 AS salary
) AS UNUSED_TABLE_NAME )
SELECT
Employee.name AS name,
Employee.salary AS salary
FROM
t_0_Employee AS Employee
WHERE
(Employee.department = 'Engineering') ORDER BY name;
Inline facts compile to a WITH clause; a real database table compiles to a plain FROM over that table, as in Querying a CSV file below.
Executing the SQL¶
Synalog produces plain SQL strings, so you can execute them with any driver for your engine: sqlite3, duckdb, psycopg, google-cloud-bigquery, and so on:
import duckdb
result = duckdb.sql(sql).fetchall()
print(result)
# [('Alice', 75000), ('Charlie', 80000)]
The CLI¶
Everything above also works without writing Python. Installing the package installs a synalog command that validates, compiles and runs predicates in one step. run (and print) verify the whole program first and stop with the verifier's errors if it is invalid:
Running synalog with no arguments starts an interactive session where you build a program rule by rule and query it as you go. With uv, uvx runs the CLI without installing anything: uvx synalog program.l run EngineeringTeam (duckdb and the PostgreSQL driver are bundled). See CLI interface.
Add the skill to your coding agent¶
Synalog ships an Agent Skill, a SKILL.md that teaches a coding agent the language, the CLI and the conventions. It follows the open Agent Skills standard, so it works with Claude Code, Cursor, Codex, OpenCode and many other agents. Install it with the skills CLI (GitHub is the registry, so there is nothing to publish or install first):
$ npx skills add SynaLinks/synalog # this project
$ npx skills add SynaLinks/synalog -g # user-wide, across all projects
Then lay out the project as tables, concepts and rules, one predicate per file. See Add the skill to your coding agent for the per-agent options.
Querying a CSV file¶
Real data lives in files or database tables, not inline facts. Here it is a small smoke-test dataset, in the smoke project:
test_id,device,status,duration_ms,run_at
1,sensor-a,passed,532,2026-06-10 08:00:12
2,sensor-b,passed,488,2026-06-10 08:00:15
3,gateway,failed,2150,2026-06-10 08:00:21
4,sensor-a,passed,540,2026-06-10 20:00:09
5,sensor-b,flaky,1102,2026-06-10 20:00:14
6,gateway,failed,2310,2026-06-10 20:00:20
7,sensor-a,passed,529,2026-06-11 08:00:11
8,sensor-b,passed,495,2026-06-11 08:00:13
9,gateway,passed,1874,2026-06-11 08:00:19
10,sensor-a,failed,1980,2026-06-11 20:00:10
11,sensor-b,passed,501,2026-06-11 20:00:16
12,gateway,passed,1822,2026-06-11 20:00:22
smoke/
├── data/smoke_tests.csv
├── tables/SmokeTests.l
├── concepts/Device.l
├── concepts/TestStatus.l
├── rules/FailuresByDevice.l
├── rules/RunsPerDay.l
└── layer.toml
The table file maps the database table smoke_tests, referenced by its lowercase database name, to the SmokeTests predicate; everything else builds on it:
---
name: SmokeTests
description: One row per smoke test run, with its device, status, duration and time.
---
@OrderBy(SmokeTests, "test_id");
SmokeTests(test_id:, device:, status:, duration_ms:, run_at:) :-
smoke_tests(test_id:, device:, status:, duration_ms:, run_at:);
A concept extracts the devices, and a rule counts the failures of each, importing both:
---
name: Device
description: The devices smoke tests run on.
---
import tables.SmokeTests.SmokeTests;
@OrderBy(Device, "device");
Device(device:) distinct :- SmokeTests(device:);
---
name: FailuresByDevice
description: Number of failed smoke tests per device, most failures first.
keywords: [failures, errors, broken devices]
---
import concepts.Device.Device;
import tables.SmokeTests.SmokeTests;
@OrderBy(FailuresByDevice, "failures DESC");
FailuresByDevice(device:, failures? += 1) distinct :-
Device(device:), SmokeTests(device:, status: "failed");
From the project's folder, the CLI loads the CSV as the smoke_tests table and runs the rule:
From Python, load it into DuckDB and give the project's folder as the import root:
import duckdb
import synalog
conn = duckdb.connect()
conn.execute("CREATE TABLE smoke_tests AS SELECT * FROM read_csv('smoke/data/smoke_tests.csv')")
source = open("smoke/rules/FailuresByDevice.l").read()
errors, warnings = synalog.check(source, import_root=["smoke"])
assert errors == []
sql = synalog.compile(source, "FailuresByDevice", import_root=["smoke"])
print(conn.execute(sql).fetchall())
# [('gateway', 2), ('sensor-a', 1)]
rules/RunsPerDay.l counts the runs per day through the temporal pipeline (ToString → Substr) on the run_at timestamp.
Every file of the smoke project, run on its data
-- Loaded data/smoke_tests.csv into DuckDB table smoke_tests (12 rows)
$ synalog tables/SmokeTests.l run SmokeTests
| test_id | device | status | duration_ms | run_at |
|---------|----------|--------|-------------|---------------------|
| 1 | sensor-a | passed | 532 | 2026-06-10 08:00:12 |
| 2 | sensor-b | passed | 488 | 2026-06-10 08:00:15 |
| 3 | gateway | failed | 2150 | 2026-06-10 08:00:21 |
| 4 | sensor-a | passed | 540 | 2026-06-10 20:00:09 |
| 5 | sensor-b | flaky | 1102 | 2026-06-10 20:00:14 |
| 6 | gateway | failed | 2310 | 2026-06-10 20:00:20 |
| 7 | sensor-a | passed | 529 | 2026-06-11 08:00:11 |
| 8 | sensor-b | passed | 495 | 2026-06-11 08:00:13 |
| 9 | gateway | passed | 1874 | 2026-06-11 08:00:19 |
| 10 | sensor-a | failed | 1980 | 2026-06-11 20:00:10 |
| 11 | sensor-b | passed | 501 | 2026-06-11 20:00:16 |
| 12 | gateway | passed | 1822 | 2026-06-11 20:00:22 |
(12 rows)
$ synalog concepts/Device.l run Device
| device |
|----------|
| gateway |
| sensor-a |
| sensor-b |
(3 rows)
$ synalog concepts/TestStatus.l run TestStatus
| status |
|--------|
| failed |
| flaky |
| passed |
(3 rows)
$ synalog rules/FailuresByDevice.l run FailuresByDevice
| device | failures |
|----------|----------|
| gateway | 2 |
| sensor-a | 1 |
(2 rows)
$ synalog rules/RunsPerDay.l run RunsPerDay
| day | runs |
|------------|------|
| 2026-06-10 | 6 |
| 2026-06-11 | 6 |
(2 rows)
Pagination¶
AI agents have limited context windows, so Synalog supports pagination at compile time via the limit and offset arguments:
The limit combines with the @Limit directive: the effective limit is min(limit, @Limit).
Always set @OrderBy
Pagination is only deterministic when results have a stable sort order. Put an @OrderBy directive on every concept and rule.
Choosing an engine¶
The target SQL dialect is set with the @Engine annotation in the program, or overridden with the engine keyword of the Python API functions:
Valid engines: sqlite, duckdb (default), bigquery, psql, presto, trino, databricks. See Supported engines.
Complete example¶
The same program as a standalone .l file, with the SQL it compiles to and the rows it returns on DuckDB:
# run: EngineeringTeam
@Engine("duckdb");
Employee(name: "Alice", department: "Engineering", salary: 75000);
Employee(name: "Bob", department: "Marketing", salary: 65000);
Employee(name: "Charlie", department: "Engineering", salary: 80000);
@OrderBy(EngineeringTeam, "name");
EngineeringTeam(name:, salary:) :- Employee(name:, department: "Engineering", salary:);
Generated SQL and execution results
$ synalog.check('getting_started.l')
No errors found.
$ synalog.compile('getting_started.l', 'EngineeringTeam')
-- Initializing DuckDB environment.
create schema if not exists logica_home;
-- Empty record, has to have a field by DuckDB syntax.
drop type if exists logicarecord893574736 cascade; create type logicarecord893574736 as struct(nirvana numeric);
create sequence if not exists eternal_logical_sequence;
WITH t_0_Employee AS (SELECT * FROM (
SELECT
'Alice' AS name,
'Engineering' AS department,
75000 AS salary
UNION ALL
SELECT
'Bob' AS name,
'Marketing' AS department,
65000 AS salary
UNION ALL
SELECT
'Charlie' AS name,
'Engineering' AS department,
80000 AS salary
) AS UNUSED_TABLE_NAME )
SELECT
Employee.name AS name,
Employee.salary AS salary
FROM
t_0_Employee AS Employee
WHERE
(Employee.department = 'Engineering') ORDER BY name;
-- Executed on DuckDB:
| name | salary |
|---------|--------|
| Alice | 75000 |
| Charlie | 80000 |
(2 rows)
Next steps¶
- Learn the program structure and the syntax.
- Model your data as a knowledge graph.
- Explore the complete Python API.
- Run and explore programs with the CLI interface.