The official Entity Framework Core provider for ClickHouse, built on top of ClickHouse.Driver.
await using var ctx = new AnalyticsContext();
var topPages = await ctx.PageViews
.Where(v => v.Date >= new DateOnly(2024, 1, 1))
.GroupBy(v => v.Path)
.Select(g => new { Path = g.Key, Views = g.Count() })
.OrderByDescending(x => x.Views)
.Take(10)
.ToListAsync();
public class AnalyticsContext : DbContext
{
public DbSet<PageView> PageViews { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseClickHouse("Host=localhost;Port=9000;Database=analytics");
}
public class PageView
{
public long Id { get; set; }
public string Path { get; set; }
public DateOnly Date { get; set; }
public string UserAgent { get; set; }
}| Category | ClickHouse Types | CLR Types |
|---|---|---|
| Integers | Int8/Int16/Int32/Int64, UInt8/UInt16/UInt32/UInt64 |
sbyte, short, int, long, byte, ushort, uint, ulong |
| Big integers | Int128, Int256, UInt128, UInt256 |
BigInteger |
| Floats | Float32, Float64, BFloat16 |
float, double |
| Decimals | Decimal(P,S), Decimal32(S), Decimal64(S), Decimal128(S), Decimal256(S) |
decimal or ClickHouseDecimal (use ClickHouseDecimal for Decimal128/256 to avoid .NET decimal overflow) |
| Bool | Bool |
bool |
| Strings | String, FixedString(N) |
string |
| Enums | Enum8(...), Enum16(...) |
string or C# enum |
| Date/time | Date, Date32, DateTime, DateTime64(P, 'TZ') |
DateOnly, DateTime |
| Time | Time, Time64(N) |
TimeSpan |
| UUID | UUID |
Guid |
| Network | IPv4, IPv6 |
IPAddress |
| Arrays | Array(T) |
T[] or List<T> |
| Maps | Map(K, V) |
Dictionary<K,V> |
| Tuples | Tuple(T1, ...) |
Tuple<...> or ValueTuple<...> |
| Variant | Variant(T1, T2, ...) |
object |
| Dynamic | Dynamic |
object |
| JSON | Json |
JsonNode or string |
| Geographic | Point, Ring, LineString, Polygon, MultiLineString, MultiPolygon, Geometry |
Tuple<double,double> and arrays thereof; object for Geometry |
| Wrappers | Nullable(T), LowCardinality(T) |
Unwrapped automatically |
This provider is in early development. It supports read-only queries and inserts — you can map entities to existing ClickHouse tables, query them with LINQ, and write data via SaveChanges.
Where, OrderBy, Take, Skip, Select, First, Single, Any, Count, Distinct, AsNoTracking
GroupBy with Count, LongCount, Sum, Average, Min, Max — including HAVING (.Where() after .GroupBy()), multiple aggregates in a single projection, and OrderBy on aggregate results.
Contains, StartsWith, EndsWith, IndexOf, Replace, Substring, Trim/TrimStart/TrimEnd, ToLower, ToUpper, Length, IsNullOrEmpty, Concat (and + operator)
Math.Abs, Floor, Ceiling, Round, Truncate, Pow, Sqrt, Cbrt, Exp, Log, Log2, Log10, Sign, Sin, Cos, Tan, Asin, Acos, Atan, Atan2, RadiansToDegrees, DegreesToRadians, IsNaN, IsInfinity, IsFinite, IsPositiveInfinity, IsNegativeInfinity — with both Math and MathF overloads.
SaveChanges supports INSERT operations using the driver's native InsertBinaryAsync API — RowBinary encoding with GZip compression, far more efficient than parameterized SQL.
await using var ctx = new AnalyticsContext();
ctx.PageViews.Add(new PageView
{
Id = 1,
Path = "/home",
Date = new DateOnly(2024, 6, 15),
UserAgent = "Mozilla/5.0"
});
await ctx.SaveChangesAsync();Entities transition from Added to Unchanged after save, just like any other EF Core provider.
Batch size is configurable (default 1000) — controls how many entities are accumulated before flushing to ClickHouse:
optionsBuilder.UseClickHouse("Host=localhost", o => o.MaxBatchSize(5000));For high-throughput loads that don't need change tracking, use BulkInsertAsync:
var events = Enumerable.Range(0, 100_000)
.Select(i => new PageView { Id = i, Path = $"/page/{i}", Date = DateOnly.FromDateTime(DateTime.Today) });
long rowsInserted = await ctx.BulkInsertAsync(events);This calls InsertBinaryAsync directly, bypassing EF Core's change tracker entirely. Entities are not tracked after insert.
The provider supports ClickHouse's Json column type, mapping to System.Text.Json.Nodes.JsonNode or string.
using System.Text.Json.Nodes;
public class Event
{
public long Id { get; set; }
public JsonNode? Payload { get; set; }
}
// In OnModelCreating:
entity.Property(e => e.Payload).HasColumnType("Json");Reading and writing JSON works through both SaveChanges and BulkInsertAsync:
ctx.Events.Add(new Event
{
Id = 1,
Payload = JsonNode.Parse("""{"action": "click", "x": 100, "y": 200}""")
});
await ctx.SaveChangesAsync();
var ev = await ctx.Events.Where(e => e.Id == 1).SingleAsync();
string action = ev.Payload!["action"]!.GetValue<string>(); // "click"If you prefer working with raw JSON strings, map the property as string with a Json column type — the provider will store and retrieve the raw JSON string as-is:
public class Event
{
public long Id { get; set; }
public string? Payload { get; set; } // raw JSON string
}
entity.Property(e => e.Payload).HasColumnType("Json");Limitations:
- No JSON path translation —
entity.Payload["name"]in LINQ does not translate to ClickHouse'sdata.nameSQL syntax. Filter on non-JSON columns or load entities and inspect JSON in memory. - No owned entity mapping —
.ToJson()/StructuralJsonTypeMappingis not supported. JSON columns are opaqueJsonNodeorstringvalues. JsonElement/JsonDocumentnot supported — onlyJsonNodeandstringCLR types are mapped.- NULL semantics — ClickHouse's JSON type returns
{}(empty object) for NULL values rather than SQL NULL. A row inserted withData = nullwill read back as an emptyJsonNode, notnull. - Integer precision — ClickHouse JSON stores all integers as
Int64unless the path is typed otherwise. When reading viaJsonNode, useGetValue<long>()rather thanGetValue<int>().
- UPDATE / DELETE (ClickHouse mutations are async, not OLTP-compatible)
- Migrations
- JOINs, subqueries, set operations
dotnet build
dotnet test # requires Docker (uses Testcontainers)Targets .NET 10.0, EF Core 10.