<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged clickhouse</title>
<link>https://coderlegion.com/tag/clickhouse</link>
<description>Powered by Question2Answer</description>
<item>
<title>Deploying SigNoz in 2026: A Survival Guide to ClickHouse v25+ and OTel Gotchas</title>
<link>https://coderlegion.com/28900/deploying-signoz-in-2026-a-survival-guide-to-clickhouse-v25-and-otel-gotchas</link>
<description>&lt;p&gt;Hello, fellow developers! ‍&lt;/p&gt;
&lt;p&gt;When building a modern B2B SaaS ecosystem, bulletproof observability isn't a luxury—it's a strict survival requirement. If you are handling distributed cron jobs, processing thousands of webhook deliveries, or managing isolated services, you need to know exactly what happens under the hood when things go wrong.&lt;/p&gt;
&lt;p&gt;While architecting the infrastructure for &lt;a rel=&quot;nofollow&quot; href=&quot;https://vernesoft.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=signoz-guide&amp;amp;utm_content=intro&quot;&gt;Verne Software&lt;/a&gt;, I decided to take complete control of our telemetry data by self-hosting SigNoz. The official repository makes it look incredibly easy: just run a &lt;code&gt;docker-compose up&lt;/code&gt; script and you are supposedly good to go. You think: &quot;Great, a 10-minute setup and I can get back to writing business logic.&quot; Well, not quite.&lt;/p&gt;
&lt;p&gt;The reality is that official documentation often lags behind major architectural shifts in underlying Docker images. What was supposed to be a quick deployment turned into a deep dive into ClickHouse configurations, OpenTelemetry bugs, and networking quirks.&lt;/p&gt;
&lt;p&gt;In this article, I want to share my recent battle scars. We will go through the specific traps I encountered while trying to get the modern SigNoz stack running in an isolated Docker network—and exactly how to fix them.&lt;/p&gt;
&lt;h3 id=&quot;the-target-architecture-the-happy-path-&quot;&gt;The Target Architecture (The &quot;Happy Path&quot;)&lt;/h3&gt;
&lt;p&gt;Before we dive into the errors, let's look at the final, working setup. The core principle here is strict network isolation and a highly deterministic boot sequence.&lt;/p&gt;
&lt;h4 id=&quot;network-isolation&quot;&gt;Network Isolation&lt;/h4&gt;
&lt;p&gt;The entire &lt;a rel=&quot;nofollow&quot; href=&quot;https://signoz.io/&quot;&gt;SigNoz&lt;/a&gt; stack lives inside an isolated Docker network (&lt;code&gt;verne_observability&lt;/code&gt;). The SigNoz API and UI container is additionally connected to an internal network (&lt;code&gt;verne_internal&lt;/code&gt;). This allows our core API to proxy dashboard requests without exposing the database or internal collector ports to the outside world.&lt;/p&gt;
&lt;h4 id=&quot;the-boot-sequence&quot;&gt;The Boot Sequence&lt;/h4&gt;
&lt;p&gt;In the modern stack, you can no longer start everything at once. Order matters immensely. The services must wait for their neighbors to be completely ready:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;signoz_init_clickhouse&lt;/code&gt;: A one-shot container that downloads essential UDF binaries (like histogramQuantile).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;signoz_zookeeper&lt;/code&gt;: The coordinator for ClickHouse.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;signoz_clickhouse&lt;/code&gt;: The main datastore.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;signoz_telemetrystore_migrator&lt;/code&gt;: A crucial bootstrap step that handles schemas.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;signoz_otel_collector&lt;/code&gt; &amp;amp; &lt;code&gt;signoz&lt;/code&gt;: The final data consumers.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here is how you enforce this dependency chain in your &lt;code&gt;docker-compose.yml&lt;/code&gt; to prevent race conditions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;signoz_telemetrystore_migrator:
    # ...
    depends_on:
      signoz_clickhouse:
        condition: service_healthy # Wait until DB can accept migrations

  signoz_otel_collector:
    # ...
    depends_on:
      signoz_telemetrystore_migrator:
        condition: service_completed_successfully # Start ONLY after schema is created
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;trap-1-the-clickhouse-v25-configuration-shift&quot;&gt;Trap #1: The ClickHouse v25+ Configuration Shift&lt;/h3&gt;
&lt;p&gt;The Symptom: You mount your custom clickhouse-config.xml exactly as older tutorials suggest, but ClickHouse refuses to start, throwing errors about missing paths or invalid settings at the top level.&lt;/p&gt;
&lt;p&gt;The Reality: With ClickHouse 25.5.6, the configuration paradigm has shifted. Replacing the main config.xml outright is a recipe for disaster. Furthermore, certain settings have been strictly recategorized.&lt;/p&gt;
&lt;p&gt;For instance, you might see this exact crash loop: &lt;code&gt;Code: 137. A setting 'log_queries' appeared at top level in config. But it is user-level setting that should be located in users.xml inside &amp;lt;profiles&amp;gt; section.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The Fix: Stop mounting over the default config. Instead, utilize the &lt;code&gt;config.d/&lt;/code&gt; and &lt;code&gt;users.d/&lt;/code&gt; directories for your overrides.&lt;/p&gt;
&lt;p&gt;If you want to enable query logging, you must define it specifically inside a user profile. Create a users.xml file and mount it to &lt;code&gt;/etc/clickhouse-server/users.d/users.xml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;clickhouse&amp;gt;
    &amp;lt;profiles&amp;gt;
        &amp;lt;default&amp;gt;
            &amp;lt;log_queries&amp;gt;1&amp;lt;/log_queries&amp;gt;
        &amp;lt;/default&amp;gt;
    &amp;lt;/profiles&amp;gt;
&amp;lt;/clickhouse&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keep your cluster topologies and UDF paths in config.d/, and your profiles and passwords in users.d/. Respecting this separation will save you hours of debugging.&lt;/p&gt;
&lt;h3 id=&quot;trap-2-the-invisible-migrations-amp-missing-databases&quot;&gt;Trap #2: The Invisible Migrations &amp;amp; Missing Databases&lt;/h3&gt;
&lt;p&gt;The Symptom: You fire up your stack, and the OpenTelemetry Collector immediately crash-loops, screaming: Database signoz_traces does not exist.&lt;/p&gt;
&lt;p&gt;The Reality: In older iterations, you could often get away with starting the datastore and the collector simultaneously. The schemas were either auto-created on the fly or bundled differently. Today, schema creation is strictly decoupled from the application logic. If you just start ClickHouse and the OTel Collector, the necessary databases (signoz_traces, signoz_metrics, signoz_logs) simply will not exist.&lt;/p&gt;
&lt;p&gt;The Fix: You need to embrace the one-shot container pattern. Specifically, you must introduce ephemeral containers that do the dirty work before the main services are allowed to boot.&lt;/p&gt;
&lt;p&gt;First, as mentioned in our boot sequence, signoz_init_clickhouse must run to download essential User Defined Functions (like the histogramQuantile binary) directly into the ClickHouse user_scripts directory.&lt;/p&gt;
&lt;p&gt;Second, and most importantly, you need the signoz_telemetrystore_migrator. This container handles the complex bootstrap process and synchronizes the schemas. Your collector should only run after this migrator exits with a success code.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# The crucial pre-flight check for the collector
  signoz_otel_collector:
    image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.111.10}
    command:
      - -c
      - |
        /signoz-otel-collector migrate sync check &amp;amp;&amp;amp;
        /signoz-otel-collector --config=/etc/otel-collector-config.yaml
    depends_on:
      signoz_telemetrystore_migrator:
        condition: service_completed_successfully
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By explicitly chaining migrate sync check in the command and waiting for the migrator container, you eliminate the race condition completely.&lt;/p&gt;
&lt;h3 id=&quot;trap-3-taming-the-opentelemetry-collector&quot;&gt;Trap #3: Taming the OpenTelemetry Collector&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The Symptom&lt;/strong&gt;: The Collector fails to start due to configuration parsing errors, missing image tags, or deprecated flags.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Reality&lt;/strong&gt;: The OpenTelemetry ecosystem moves incredibly fast, and SigNoz's custom collector is no exception. Between versions, breaking changes are silently introduced, turning perfectly good configuration files into landmines.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Fixes&lt;/strong&gt;: Here are the three specific collector headaches you need to watch out for:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Image Tagging Roulette: If you are pinning versions in your .env file (which you absolutely should be doing in production), pay attention to the exact naming convention. Dropping the v prefix (e.g., trying to pull 0.144.2 instead of v0.144.2) will result in an image pull failure. It sounds trivial, but it's a remarkably common stumbling block when updating the stack.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Renamed Exporter: The telemetry pipeline configuration has evolved. If you are migrating an older otel-collector-config.yaml, you must update the metrics exporter name. The legacy clickhousemetricswrite has been deprecated and replaced. You now need to use signozclickhousemetrics to route your data correctly.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Feature Gates: Clean up your CLI commands. Deprecated flags (like -pkg.translator.prometheus.NormalizeName) that used to just trigger harmless CLI warnings will now cause fatal container crashes because those features have been stabilized and the flags removed. Keep your command array strictly to the bare minimum required to pass the config file.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;trap-4-pragmatic-security-the-passwordless-docker-network-&quot;&gt;Trap #4: Pragmatic Security (The Passwordless Docker Network)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The Symptom&lt;/strong&gt;: You try to follow security best practices by adding authentication to every component, but the stack becomes a house of cards. Specifically, Bitnami’s ZooKeeper refuses to start, and the OpenTelemetry Collector fails to connect to ClickHouse despite &quot;correct&quot; credentials in the DSN.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Reality&lt;/strong&gt;: We often fight two battles here. First, modern Bitnami images (often used for ZooKeeper) are secure by default and will simply exit if no authentication is configured. Second, OpenTelemetry configuration files are notoriously finicky with environment variable interpolation in DSN strings—one misplaced character in a password can break the entire pipeline.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Fix&lt;/strong&gt;: Embrace network-level isolation as your primary defense. In the official SigNoz approach, and the one we adopted for Verne Software, ClickHouse runs without a password internally. Security is enforced by the fact that the &lt;code&gt;verne_observability&lt;/code&gt; network is not accessible from the outside world.&lt;/p&gt;
&lt;p&gt;To get the stack running smoothly, apply these two adjustments:&lt;/p&gt;
&lt;p&gt;For &lt;strong&gt;ZooKeeper&lt;/strong&gt;: Explicitly allow anonymous logins in your docker-compose.yml. This is acceptable because ZooKeeper is buried deep within your private network.&lt;/p&gt;
&lt;p&gt;For &lt;strong&gt;ClickHouse&lt;/strong&gt;: Use a clean, passwordless DSN in your OTel and SigNoz API configurations. It eliminates the &quot;interpolation hell&quot; and simplifies the handshake between services.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;signoz_zookeeper:
    image: bitnami/zookeeper:3.9.2
    environment:
      - ALLOW_ANONYMOUS_LOGIN=yes # Required for the stack to boot without complex SASL setup
    networks:
      - verne_observability
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By relying on the Docker network boundary, you reduce configuration complexity while maintaining a solid security posture for your internal telemetry.&lt;/p&gt;
&lt;h3 id=&quot;conclusion-the-real-cost-of-self-hosting&quot;&gt;Conclusion: The Real Cost of Self-Hosting&lt;/h3&gt;
&lt;p&gt;After navigating these four traps, my docker-compose ps finally shows a beautiful sight: six containers humming in perfect harmony. From the one-shot UDF initializer to the perfectly synchronized telemetry migrator, the stack is now a reliable engine for our observability needs.&lt;/p&gt;
&lt;p&gt;However, the main takeaway from this journey is clear: &lt;strong&gt;infrastructure is a full-time job&lt;/strong&gt;. While owning your telemetry data is empowering, maintaining the underlying &quot;plumbing&quot;—the databases, collectors, and ever-shifting configurations—is a massive tax on engineering time.&lt;/p&gt;
&lt;p&gt;This experience is exactly what shapes our philosophy at &lt;a rel=&quot;nofollow&quot; href=&quot;https://vernesoft.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=signoz-guide&amp;amp;utm_content=conclusion&quot;&gt;Verne Software&lt;/a&gt;. We believe developers should spend their &quot;innovation tokens&quot; on building core business logic, not on taming infrastructure monsters. It is why we are building the Nautilus ecosystem—to provide powerful &quot;as-a-Service&quot; tools that just work.&lt;/p&gt;
&lt;p&gt;If you’re interested in seeing how we’re applying these lessons to simplify developer workflows, we’ve just launched the beta version of our platform at &lt;a rel=&quot;nofollow&quot; href=&quot;https://vernesoft.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=signoz-guide&amp;amp;utm_content=cta&quot;&gt;vernesoft.com&lt;/a&gt;. You can also dive into our growing documentation at &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.vernesoft.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=signoz-guide&amp;amp;utm_content=docs&quot;&gt;docs.vernesoft.com&lt;/a&gt; to see what we’re building next.&lt;/p&gt;
&lt;p&gt;Stay pragmatic, and may your logs always be searchable!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28900/deploying-signoz-in-2026-a-survival-guide-to-clickhouse-v25-and-otel-gotchas</guid>
<pubDate>Mon, 28 Sep 2026 10:42:43 +0000</pubDate>
</item>
<item>
<title>OpenSearch, Loki, Quickwit and ClickHouse: Which Platform Should You Choose for Logs and Observability in 2026?</title>
<link>https://coderlegion.com/23249/opensearch-loki-quickwit-clickhouse-which-platform-should-choose-logs-observability-2026</link>
<description>
&lt;a href=&quot;https://alexandre-vazquez.com/opensearch-loki-quickwit-clickhouse-observability-2026/&quot; title=&quot;OpenSearch, Loki, Quickwit and ClickHouse: Which Platform Should You Choose for Logs and Observability in 2026?&quot; rel=&quot;nofollow&quot;&gt;&lt;img width=&quot;1536&quot; height=&quot;1024&quot; src=&quot;https://mlk5tkvgfeoa.i.optimole.com/cb:s8WW.68/w:1536/h:1024/q:mauto/f:best/https://alexandre-vazquez.com/wp-content/uploads/2026/07/observ.avif&quot; class=&quot;webfeedsFeaturedVisual wp-post-image&quot; alt=&quot;OpenSearch, Loki, Quickwit and ClickHouse: Which Platform Should You Choose for Logs and Observability in 2026?&quot; style=&quot;display: block; margin: auto; margin-bottom: 5px;max-width: 100%;&quot; title=&quot;OpenSearch, Loki, Quickwit and ClickHouse: Which Platform Should You Choose for Logs and Observability in 2026? 2&quot;&gt;&lt;/a&gt;
&lt;p&gt;Log management and observability are no longer a matter of &amp;ldquo;storing text and searching it quickly.&amp;rdquo; In 2026, the real challenge is economic and architectural: ingesting growing volumes, retaining data for months, correlating logs with metrics and traces, and responding during an incident without costs spiraling out of control.&lt;/p&gt;



&lt;p&gt;For years, Elasticsearch was the dominant option. Then came OpenSearch, Loki, Quickwit and, with significant momentum, ClickHouse as an analytical backend. All of them can solve part of the problem, but they do not optimize for the same things. The senior decision should not be &amp;ldquo;which one is faster&amp;rdquo; in the abstract, but which questions the team will ask, what retention it needs and how much complexity it can operate.&lt;/p&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;The Historical Standard: Elasticsearch and OpenSearch&lt;/h2&gt;



&lt;p&gt;OpenSearch began as an open source fork of Elasticsearch and keeps the mental model most familiar to many teams: indices, shards, replicas, mappings, Query DSL, aggregations and a mature full-text experience. If your organization already comes from Elasticsearch, it is the most direct transition.&lt;/p&gt;



&lt;p&gt;Architecturally, it is built on Apache Lucene. Each index is divided into shards; each shard maintains segments and inverted indices; the cluster coordinates writes, replicas, distributed searches and aggregations. This provides a great deal of power, but also a clear operational footprint: JVM, heap sizing, merges, hot/warm/cold tiers and mapping tuning.&lt;/p&gt;



&lt;p&gt;For logs, OpenSearch shines when you need expressive text search, rich filters, aggregations, mature dashboards, existing tooling or use cases close to SIEM. The cost appears as volume grows: indexing a lot of content makes it fast to search, but consumes storage and memory.&lt;/p&gt;



&lt;p&gt;In 2026, the 3.x branch also matters. OpenSearch 3.0 introduced relevant changes such as Lucene 10 and JVM 21, along with performance improvements and vector search capabilities. That signals project vitality, but it also means upgrades need more careful planning than in a purely stateless stack.&lt;/p&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;Loki: Observability Centered on Efficiency&lt;/h2&gt;



&lt;p&gt;Grafana Loki takes a different philosophy: do not index the full content of logs, only the labels. In practice, it is closer to &amp;ldquo;Prometheus for logs&amp;rdquo; than to Elasticsearch: queries by labels and subsequent filtering over lines with LogQL.&lt;/p&gt;



&lt;p&gt;Its storage is based on compressed chunks and a small index. Chunks usually live in object storage such as S3, GCS, Azure Blob or MinIO, while the index points to streams defined by label combinations. That decision reduces cost, but shifts responsibility to label design.&lt;/p&gt;



&lt;p&gt;The practical rule is simple: labels must have controlled cardinality. &lt;code&gt;cluster&lt;/code&gt;, &lt;code&gt;namespace&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;environment&lt;/code&gt; or &lt;code&gt;service&lt;/code&gt; usually work well. &lt;code&gt;request_id&lt;/code&gt;, &lt;code&gt;user_id&lt;/code&gt; or &lt;code&gt;trace_id&lt;/code&gt; as labels can break the model because they multiply streams, hurt compaction and grow the index. For those values, it is better to filter log content or rely on traces.&lt;/p&gt;



&lt;p&gt;Loki shines in Kubernetes because the label model fits pods, namespaces and services. Its Grafana integration is also excellent: log exploration, jumps from &lt;a rel=&quot;nofollow&quot; href=&quot;https://alexandre-vazquez.com/prometheus-servicemonitor-podmonitor/&quot;&gt;metrics&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://alexandre-vazquez.com/alertmanager-vs-grafana-alerting/&quot;&gt;alerts with LogQL&lt;/a&gt; and correlation with Tempo or Prometheus.&lt;/p&gt;



&lt;p&gt;Its weakness appears when you want to treat logs as an arbitrary search corpus. A query like &amp;ldquo;find this fragment across all logs from the last six months&amp;rdquo; can be much more expensive than in a system with a full-text index. Loki is designed for queries bounded by labels and time, not to replace a general-purpose search engine.&lt;/p&gt;



&lt;p&gt;In 2026, Loki remains highly active. The official documentation lists recent 3.x versions, including the 3.7 branch, and Grafana continues to position it as a horizontally scalable, highly available and cost-efficient log aggregation system. Grafana&amp;rsquo;s acquisition of Logline further reinforces the focus on difficult searches within logs, although it does not change the central principle: label design remains critical.&lt;/p&gt;



&lt;h3 class=&quot;wp-block-heading&quot;&gt;When Loki Shines&lt;/h3&gt;



&lt;p&gt;Loki is the pragmatic choice for Kubernetes teams that already live in Grafana, need to retain volume at low cost and query by service, namespace, cluster, environment and time window. It is less convincing for global full-text search or deep forensic analysis.&lt;/p&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;Quickwit: Cloud-Native Search on Object Storage&lt;/h2&gt;



&lt;p&gt;Quickwit is a distributed search platform written in Rust, designed for append-only workloads such as logs and traces. Its thesis is attractive: keep powerful search, but decouple compute and storage by using object storage as the main layer.&lt;/p&gt;



&lt;p&gt;Instead of depending on hot local disks, Quickwit writes indices and splits to S3-compatible storage, Azure Blob, Google Cloud Storage or MinIO. Indexers process data, searchers query segments and the metastore coordinates state. The model uses inverted indices optimized for immutable data and remote storage: closer to OpenSearch in search than Loki, but with cloud-native economics.&lt;/p&gt;



&lt;p&gt;Quickwit is especially interesting for teams that want to replace part of an expensive Elasticsearch/OpenSearch cluster, already have logs in S3 or can move them there, and need text search with a better operational cost profile. It also has Grafana integration and APIs that are partially compatible with Elasticsearch/OpenSearch, which helps with some existing workflows.&lt;/p&gt;



&lt;p&gt;The critical nuance for 2026 is its project status. Datadog acquired Quickwit in 2024. The acquisition did not mean the open source repository disappeared: the &lt;code&gt;quickwit-oss/quickwit&lt;/code&gt; repository remains public and shows recent activity, including 2026 tags and discussions. The Quickwit Grafana plugin also publishes recent compatibility with Grafana 12.1+ and 13. That said, the product risk is different from Loki or OpenSearch: strategic direction is influenced by Datadog, and part of the effort may be oriented toward internal or commercial use cases.&lt;/p&gt;



&lt;p&gt;The honest reading is this: Quickwit should not be dismissed as &amp;ldquo;dead,&amp;rdquo; because according to the reviewed sources it is not archived. But it should be evaluated with more diligence: release cadence, critical issues, roadmap, real API compatibility, operational ease and dependency on the maintainer team. For a core observability platform, that evaluation matters as much as the benchmark.&lt;/p&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;ClickHouse: The Analytical Engine of New Observability&lt;/h2&gt;



&lt;p&gt;ClickHouse was born as a columnar analytical database, not as a log platform. That is exactly why it has become relevant to observability. Many incidents are not solved by searching for an exact string, but by grouping by service, calculating percentiles, exploring cardinalities, joining logs with traces and querying billions of events over a time range.&lt;/p&gt;



&lt;p&gt;ClickHouse&amp;rsquo;s model is columnar. Data is stored by columns, compresses extremely well and is queried with vectorized execution, ordered primary indices, data skipping indexes and engines such as MergeTree. For observability, this makes it possible to store wide events and read only the columns needed.&lt;/p&gt;



&lt;p&gt;The tradeoff is that ClickHouse is not Elasticsearch. Full-text search exists and has improved, but its strength remains structured and semi-structured analysis. If your queries are &amp;ldquo;give me all 500 errors by version, region and endpoint in the last 15 minutes,&amp;rdquo; ClickHouse is a very good fit. If your main query is &amp;ldquo;find any line containing a rare phrase across six months of unstructured text,&amp;rdquo; you probably want another component or a hybrid strategy.&lt;/p&gt;



&lt;p&gt;ClickHouse has moved decisively toward observability. The acquisition of HyperDX and the launch of ClickStack change its positioning: it is no longer only &amp;ldquo;use ClickHouse as the backend and build the UI yourself,&amp;rdquo; but a stack with OpenTelemetry, HyperDX as the interface and ClickHouse as the engine. It shines in high-volume platforms, structured logs, traces, derived metrics and low-latency aggregate queries, although it requires understanding modeling, TTLs, compression and ingestion costs.&lt;/p&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;Quick Comparison&lt;/h2&gt;



&lt;figure class=&quot;wp-block-table&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Characteristic&lt;/th&gt;&lt;th&gt;OpenSearch&lt;/th&gt;&lt;th&gt;Loki&lt;/th&gt;&lt;th&gt;Quickwit&lt;/th&gt;&lt;th&gt;ClickHouse&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Main model&lt;/td&gt;&lt;td&gt;Distributed search on Lucene&lt;/td&gt;&lt;td&gt;Logs by streams and labels&lt;/td&gt;&lt;td&gt;Cloud-native distributed search&lt;/td&gt;&lt;td&gt;Columnar analytical database&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Storage model&lt;/td&gt;&lt;td&gt;Shards, Lucene segments, hot/warm/cold tiers&lt;/td&gt;&lt;td&gt;Compressed chunks + small index&lt;/td&gt;&lt;td&gt;Indices/splits in object storage&lt;/td&gt;&lt;td&gt;Compressed columns in MergeTree&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Index&lt;/td&gt;&lt;td&gt;Inverted, very complete&lt;/td&gt;&lt;td&gt;Labels, not full content&lt;/td&gt;&lt;td&gt;Inverted, optimized for object storage&lt;/td&gt;&lt;td&gt;Primary index, data skipping, columns&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Full-text search&lt;/td&gt;&lt;td&gt;Excellent&lt;/td&gt;&lt;td&gt;Limited and dependent on filters&lt;/td&gt;&lt;td&gt;Very good&lt;/td&gt;&lt;td&gt;Good, not its main strength&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Relative cost&lt;/td&gt;&lt;td&gt;High&lt;/td&gt;&lt;td&gt;Low&lt;/td&gt;&lt;td&gt;Low-medium&lt;/td&gt;&lt;td&gt;Low-medium&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Query latency&lt;/td&gt;&lt;td&gt;Low on well-sized hot indices&lt;/td&gt;&lt;td&gt;Low if bounded by labels; worse on broad scans&lt;/td&gt;&lt;td&gt;Low-medium depending on cache and object storage&lt;/td&gt;&lt;td&gt;Very low on well-modeled aggregations&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Scaling&lt;/td&gt;&lt;td&gt;Mature, but operationally involved&lt;/td&gt;&lt;td&gt;Horizontal and economical&lt;/td&gt;&lt;td&gt;Decouples compute and storage&lt;/td&gt;&lt;td&gt;Excellent for analytics and ingestion&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Kubernetes&lt;/td&gt;&lt;td&gt;Good&lt;/td&gt;&lt;td&gt;Excellent&lt;/td&gt;&lt;td&gt;Good&lt;/td&gt;&lt;td&gt;Good, better with ClickStack/OTel&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Grafana&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;td&gt;Native&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Elasticsearch compatibility&lt;/td&gt;&lt;td&gt;High in OpenSearch&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;td&gt;Partial&lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Best use case&lt;/td&gt;&lt;td&gt;Complex search and compatibility&lt;/td&gt;&lt;td&gt;Low-cost Kubernetes logs&lt;/td&gt;&lt;td&gt;Efficient search on S3&lt;/td&gt;&lt;td&gt;Unified analytical observability&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Main risk&lt;/td&gt;&lt;td&gt;Cost and complexity&lt;/td&gt;&lt;td&gt;Label cardinality&lt;/td&gt;&lt;td&gt;Ecosystem and governance after acquisition&lt;/td&gt;&lt;td&gt;Modeling and SQL/operations learning curve&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/figure&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;Which Should You Choose in 2026?&lt;/h2&gt;



&lt;p&gt;The answer depends less on technology fashion and more on the team&amp;rsquo;s profile.&lt;/p&gt;



&lt;h3 class=&quot;wp-block-heading&quot;&gt;If You Are a Platform Team with Elasticsearch Heritage&lt;/h3&gt;



&lt;p&gt;OpenSearch is the conservative option if you already have automation, alerts and operational knowledge around Elasticsearch/OpenSearch. Do not migrate &amp;ldquo;as is&amp;rdquo; expecting costs to drop: review mappings, retention, ILM/ISM, shards, indexed fields and the separation between hot data and archive.&lt;/p&gt;



&lt;h3 class=&quot;wp-block-heading&quot;&gt;If You Are a Kubernetes Team Centered on Grafana&lt;/h3&gt;



&lt;p&gt;Loki is usually the first option. It is efficient, integrates very well with Grafana and reduces the cost of operational logs. The key is to govern labels from day one: allowed taxonomy, cardinality limits and context-driven queries before text searches.&lt;/p&gt;



&lt;h3 class=&quot;wp-block-heading&quot;&gt;If You Want to Reduce Cost Versus OpenSearch Without Losing Search&lt;/h3&gt;



&lt;p&gt;Quickwit deserves a serious PoC when Loki falls short on search and OpenSearch is expensive. The decision must include continuity: repository activity, releases, clients, community support, Grafana integration and Datadog&amp;rsquo;s position.&lt;/p&gt;



&lt;h3 class=&quot;wp-block-heading&quot;&gt;If You Want a Unified Observability Platform&lt;/h3&gt;



&lt;p&gt;ClickHouse is probably the strongest bet for consolidating logs, traces, derived metrics and events in a shared analytical database. ClickStack and HyperDX reduce the need to build the entire experience from scratch, but adoption depends on maturity in SQL, modeling and analytical database operations.&lt;/p&gt;



&lt;h3 class=&quot;wp-block-heading&quot;&gt;If You Have Security, Audit or SIEM Requirements&lt;/h3&gt;



&lt;p&gt;OpenSearch remains more natural for textual investigation and security tooling. ClickHouse can work for security analytics at scale, but it requires modeling. Loki is rarely the first choice for SIEM. Quickwit can be interesting, with a smaller ecosystem.&lt;/p&gt;



&lt;h3 class=&quot;wp-block-heading&quot;&gt;If Budget Is the Bottleneck&lt;/h3&gt;



&lt;p&gt;Do not choose only by storage cost per TB. Calculate total cost: ingestion, queries, retention, operations, backups, upgrades, training and incident time. Loki and ClickHouse usually win on raw cost; Quickwit can be highly competitive on object storage; OpenSearch can be reasonable if it is limited to data that is truly searchable and hot.&lt;/p&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;How to Frame a Decision PoC&lt;/h2&gt;



&lt;p&gt;A useful test is not installing four Helm charts and looking at a demo. Define real questions from past incidents and run them against representative data:&lt;/p&gt;



&lt;ul class=&quot;wp-block-list&quot;&gt;

&lt;li&gt;The last 7 days of production logs, with real cardinality.&lt;/li&gt;


&lt;li&gt;A hot 2-hour window with high volume.&lt;/li&gt;


&lt;li&gt;A rare text search over a broad period.&lt;/li&gt;


&lt;li&gt;An aggregation by service, endpoint, version and region.&lt;/li&gt;


&lt;li&gt;A correlation flow from alert to logs and traces.&lt;/li&gt;

&lt;/ul&gt;



&lt;p&gt;Measure sustained ingestion, storage cost, p50/p95 latency, operational complexity and team experience. Include an uncomfortable scenario: a failed node, slow object storage, unexpected cardinality or a badly written query.&lt;/p&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;Conclusion&lt;/h2&gt;



&lt;p&gt;The era when Elasticsearch was the only viable option for logs is over. In 2026, there are four clear paths.&lt;/p&gt;



&lt;p&gt;OpenSearch is the mature choice for full-text search and compatibility. Loki is efficient for operational logs in Kubernetes. Quickwit offers modern search on object storage, with mandatory governance evaluation after the Datadog acquisition. ClickHouse is consolidating as an analytical engine for unified observability, especially with ClickStack and HyperDX.&lt;/p&gt;



&lt;p&gt;The best platform is not the one that wins every benchmark. It is the one that answers your production questions with the lowest sustainable cost and the least operational friction for your team.&lt;/p&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;CTA: Decide with Data, Not Preferences&lt;/h2&gt;



&lt;p&gt;Before migrating or standardizing, run a two-week PoC with real data, real queries and an honest estimate of total cost. It should reveal which data belongs in search, which belongs in analytics and which belongs in cheap storage.&lt;/p&gt;



&lt;h2 class=&quot;wp-block-heading&quot;&gt;Sources&lt;/h2&gt;



&lt;ul class=&quot;wp-block-list&quot;&gt;

&lt;li&gt;OpenSearch 3.0: &lt;a href=&quot;https://opensearch.org/blog/unveiling-opensearch-3-0/&quot; rel=&quot;nofollow&quot;&gt;https://opensearch.org/blog/unveiling-opensearch-3-0/&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;OpenSearch 3.0, Lucene 10 and JVM 21: &lt;a href=&quot;https://opensearch.org/blog/opensearch-3-0-what-to-expect/&quot; rel=&quot;nofollow&quot;&gt;https://opensearch.org/blog/opensearch-3-0-what-to-expect/&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;Grafana Loki, documentation: &lt;a href=&quot;https://grafana.com/docs/loki/latest/&quot; rel=&quot;nofollow&quot;&gt;https://grafana.com/docs/loki/latest/&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;Grafana Loki, releases 3.x: &lt;a href=&quot;https://grafana.com/docs/loki/latest/release-notes/&quot; rel=&quot;nofollow&quot;&gt;https://grafana.com/docs/loki/latest/release-notes/&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;Grafana Loki, labels and cardinality: &lt;a href=&quot;https://grafana.com/blog/how-labels-in-loki-can-make-log-queries-faster-and-easier/&quot; rel=&quot;nofollow&quot;&gt;https://grafana.com/blog/how-labels-in-loki-can-make-log-queries-faster-and-easier/&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;Datadog acquires Quickwit: &lt;a href=&quot;https://www.datadoghq.com/blog/datadog-acquires-quickwit/&quot; rel=&quot;nofollow&quot;&gt;https://www.datadoghq.com/blog/datadog-acquires-quickwit/&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;Quickwit OSS: &lt;a href=&quot;https://github.com/quickwit-oss/quickwit&quot; rel=&quot;nofollow&quot;&gt;https://github.com/quickwit-oss/quickwit&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;Quickwit datasource for Grafana: &lt;a href=&quot;https://github.com/quickwit-oss/quickwit-datasource&quot; rel=&quot;nofollow&quot;&gt;https://github.com/quickwit-oss/quickwit-datasource&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;ClickStack: &lt;a href=&quot;https://clickhouse.com/clickstack&quot; rel=&quot;nofollow&quot;&gt;https://clickhouse.com/clickstack&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;ClickStack/HyperDX in ClickHouse Cloud: &lt;a href=&quot;https://clickhouse.com/docs/use-cases/observability/clickstack/overview&quot; rel=&quot;nofollow&quot;&gt;https://clickhouse.com/docs/use-cases/observability/clickstack/overview&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;ClickHouse acquires HyperDX: &lt;a href=&quot;https://clickhouse.com/blog/202504-newsletter&quot; rel=&quot;nofollow&quot;&gt;https://clickhouse.com/blog/202504-newsletter&lt;/a&gt;&lt;/li&gt;


&lt;li&gt;ClickHouse and observability 2026: &lt;a href=&quot;https://clickhouse.com/resources/engineering/what-is-observability&quot; rel=&quot;nofollow&quot;&gt;https://clickhouse.com/resources/engineering/what-is-observability&lt;/a&gt;&lt;/li&gt;

&lt;/ul&gt;
&lt;div class=&quot;yarpp yarpp-related yarpp-related-rss yarpp-template-list&quot;&gt;
&amp;lt;!-- YARPP List --&amp;gt;
&lt;h2&gt;Related articles:&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://alexandre-vazquez.com/alertmanager-vs-grafana-alerting/&quot; rel=&quot;bookmark nofollow&quot; title=&quot;Prometheus Alertmanager vs Grafana Alerting (2026): Architecture, Features, and When to Use Each&quot;&gt;Prometheus Alertmanager vs Grafana Alerting (2026): Architecture, Features, and When to Use Each&lt;/a&gt; &lt;small&gt;Should you standardize on Prometheus Alertmanager or move everything into...&lt;/small&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://alexandre-vazquez.com/prometheus-scalability/&quot; rel=&quot;bookmark nofollow&quot; title=&quot;Prometheus Scalability: High Cardinality and How to Fix It&quot;&gt;Prometheus Scalability: High Cardinality and How to Fix It&lt;/a&gt; &lt;small&gt;Prometheus has become the de facto standard for metrics collection...&lt;/small&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://alexandre-vazquez.com/eks-auto-mode/&quot; rel=&quot;bookmark nofollow&quot; title=&quot;EKS Auto Mode: What It Actually Changes (and What It Doesn&amp;rsquo;t)&quot;&gt;EKS Auto Mode: What It Actually Changes (and What It Doesn&amp;rsquo;t)&lt;/a&gt; &lt;small&gt;What EKS Auto Mode is EKS Auto Mode, generally available...&lt;/small&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</description>
<category>Other</category>
<guid isPermaLink="true">https://coderlegion.com/23249/opensearch-loki-quickwit-clickhouse-which-platform-should-choose-logs-observability-2026</guid>
<pubDate>Thu, 23 Jul 2026 10:00:00 +0000</pubDate>
</item>
<item>
<title>ClickHouse system tables ate my disk (and the fix)</title>
<link>https://coderlegion.com/22171/clickhouse-system-tables-ate-my-disk-and-the-fix</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A monitoring alert said I was dropping check results. The disk was 100% full. My actual data was 20 MB. ClickHouse had quietly written 12 GB of logs about itself, mostly &lt;code&gt;system.text_log&lt;/code&gt; and &lt;code&gt;system.trace_log&lt;/code&gt;. Those same logs also burn CPU at idle. The fix is a few lines of ClickHouse config that disable the noisy logs and slow the metrics collector. Full config is below.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;the-alert&quot;&gt;The alert&lt;/h3&gt;
&lt;p&gt;One morning a critical alert fired: &lt;code&gt;UptimepageResultsLost&lt;/code&gt;. Its description is blunt: storage write failures or dropped results, checks run but results are not persisting. Then it did something worse than fire once. It cleared, fired again, cleared, and fired again, over and over.&lt;/p&gt;
&lt;p&gt;I run Uptimepage, an uptime monitoring service. &quot;Results not persisting&quot; means the one thing customers pay for, recording whether their sites are up, might be failing. So it had my full attention.&lt;/p&gt;
&lt;p&gt;The good news first: no data was lost. The write path retries, and every failed write was caught by a retry. I keep a counter for results that actually get dropped, and it stayed at zero the whole time. But something was clearly wrong underneath.&lt;/p&gt;
&lt;h3 id=&quot;the-real-signal-a-full-disk&quot;&gt;The real signal: a full disk&lt;/h3&gt;
&lt;p&gt;The first real signal came from Postgres, which had crashed and restarted a couple of minutes earlier:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FATAL: could not write lock file &quot;postmaster.pid&quot;: No space left on device
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The disk was 100% full. Postgres could not write its lock file, crashed, and recovered on its own through WAL replay. ClickHouse, &lt;a rel=&quot;nofollow&quot; href=&quot;https://uptimepage.dev/blog/postgres-vs-clickhouse-uptime-monitor&quot;&gt;where I store raw check results&lt;/a&gt;, was rejecting inserts with its own version of the same complaint:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Code: 243. DB::Exception: Cannot reserve 1.00 MiB, not enough space:
While executing WaitForAsyncInsert. (NOT_ENOUGH_SPACE)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the alert was a symptom. The real problem was a full disk. That reframes the question: I do not store much, so what filled it?&lt;/p&gt;
&lt;h3 id=&quot;leak-one-old-docker-images&quot;&gt;Leak one: old Docker images&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;docker system df&lt;/code&gt; gave the first half of the answer:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://uptimepage.dev/static/marketing/blog-disk-usage-breakdown.webp&quot; alt=&quot;Docker disk usage broken down: images 16.2 GB, volumes 13.8 GB (Postgres and ClickHouse data), build cache 3.8 GB, containers 2.5 GB&quot;&gt;&lt;/p&gt;
&lt;p&gt;88 Docker images, only two of them in use. Every deploy pulls a fresh image, and nothing pruned the old ones, so they piled up for weeks. A &lt;code&gt;docker image prune -af&lt;/code&gt; reclaimed about 10 GB and took the disk off the ceiling.&lt;/p&gt;
&lt;p&gt;That stopped the bleeding. But 13.8 GB of volumes is a lot for a service whose data I thought was tiny. That number turned out to be the real story.&lt;/p&gt;
&lt;h3 id=&quot;leak-two-clickhouse-logging-about-itself&quot;&gt;Leak two: ClickHouse logging about itself&lt;/h3&gt;
&lt;p&gt;I went into ClickHouse and asked the obvious question, which table is big:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT database, formatReadableSize(sum(bytes_on_disk)) AS size, sum(rows) AS rows
FROM system.parts
WHERE active
GROUP BY database
ORDER BY sum(bytes_on_disk) DESC
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The answer stopped me:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Database &lt;/th&gt;&lt;th&gt;Size &lt;/th&gt;&lt;th&gt;Rows&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;system&lt;/code&gt; &lt;/td&gt;&lt;td&gt;11.8 GiB &lt;/td&gt;&lt;td&gt;577,340,927&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;monitor&lt;/code&gt; &lt;/td&gt;&lt;td&gt;19.7 MiB &lt;/td&gt;&lt;td&gt;1,813,884&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;monitor&lt;/code&gt; is my data: every check result and rollup I keep. About 20 MB. The &lt;code&gt;system&lt;/code&gt; database, ClickHouse's own diagnostic tables, was 11.8 GiB. Nearly all of the storage was ClickHouse logging about itself.&lt;/p&gt;
&lt;p&gt;Breaking &lt;code&gt;system&lt;/code&gt; down by table:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://uptimepage.dev/static/marketing/blog-clickhouse-system-logs.webp&quot; alt=&quot;ClickHouse system tables ranked by on-disk size: text_log 5.29 GiB, trace_log 3.02 GiB, part_log 1.11 GiB and smaller logs, next to the actual data table at 2.8 MiB&quot;&gt;&lt;/p&gt;
&lt;p&gt;Two tables did most of the damage. &lt;code&gt;system.text_log&lt;/code&gt; (5.29 GiB) is a copy of the server's own log output written into a table. &lt;code&gt;system.trace_log&lt;/code&gt; (3.02 GiB) is the query profiler, which samples running queries. Both are handy when you are actively debugging ClickHouse. Neither is worth multiple gigabytes when I am not. And &lt;code&gt;text_log&lt;/code&gt; is off by default, so something in my setup had switched it on.&lt;/p&gt;
&lt;h3 id=&quot;the-fix&quot;&gt;The fix&lt;/h3&gt;
&lt;p&gt;Two parts: reclaim the space now, and stop it coming back.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reclaim now.&lt;/strong&gt; The system log tables are throwaway diagnostics, not real data. Truncate them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;TRUNCATE TABLE IF EXISTS system.text_log;
TRUNCATE TABLE IF EXISTS system.trace_log;
-- and the rest of the system.*_log tables
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That gave back 12 GB at once and took the disk from 74% down to 41%.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stop the regrowth.&lt;/strong&gt; Truncating is a one-time cleanup. Without a cap, the logs fill right back up. The durable fix is ClickHouse config, added under &lt;code&gt;/etc/clickhouse-server/config.d/&lt;/code&gt;. I watch ClickHouse through Grafana, not these tables. So I disable almost all of them and keep only &lt;code&gt;query_log&lt;/code&gt; and &lt;code&gt;part_log&lt;/code&gt;, both bounded, for the rare hands-on debugging session:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;clickhouse&amp;gt;
    &amp;lt;!-- Sample async metrics every 60s, not every second. --&amp;gt;
    &amp;lt;asynchronous_metrics_update_period_s&amp;gt;60&amp;lt;/asynchronous_metrics_update_period_s&amp;gt;

    &amp;lt;!-- Disable the log tables. remove=&quot;1&quot; on an absent table is a no-op,
         so this list is safe to paste as-is across ClickHouse versions. --&amp;gt;
    &amp;lt;asynchronous_metric_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;asynchronous_insert_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;backup_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;error_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;crash_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;metric_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;query_metric_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;query_thread_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;query_views_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;session_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;text_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;trace_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;opentelemetry_span_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;zookeeper_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;processors_profile_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;latency_log remove=&quot;1&quot;/&amp;gt;
    &amp;lt;background_schedule_pool_log remove=&quot;1&quot;/&amp;gt;

    &amp;lt;!-- Keep query_log and part_log, bounded, for on-hand debugging. --&amp;gt;
    &amp;lt;query_log&amp;gt;
        &amp;lt;ttl&amp;gt;event_date + INTERVAL 3 DAY DELETE&amp;lt;/ttl&amp;gt;
        &amp;lt;max_size_rows&amp;gt;1048576&amp;lt;/max_size_rows&amp;gt;
    &amp;lt;/query_log&amp;gt;
    &amp;lt;part_log&amp;gt;
        &amp;lt;ttl&amp;gt;event_date + INTERVAL 3 DAY DELETE&amp;lt;/ttl&amp;gt;
        &amp;lt;max_size_rows&amp;gt;1048576&amp;lt;/max_size_rows&amp;gt;
    &amp;lt;/part_log&amp;gt;
&amp;lt;/clickhouse&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;remove=&quot;1&quot;&lt;/code&gt; disables a system log completely. The &lt;code&gt;&amp;lt;ttl&amp;gt;&lt;/code&gt; element on &lt;code&gt;query_log&lt;/code&gt; and &lt;code&gt;part_log&lt;/code&gt; adds a TTL and keeps the table's default partitioning, so you do not have to restate the whole engine. ClickHouse picks up the change after a restart. Altinity's &quot;system tables ate my disk&quot; note covers the same ground and is worth a read. If you would rather keep the diagnostics, give every log a short &lt;code&gt;&amp;lt;ttl&amp;gt;&lt;/code&gt; instead of disabling it.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Bonus: lower CPU, not just disk&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Those log tables are written constantly, and on a small server that steady write load shows up as CPU. A long-running ClickHouse issue tracks the server using noticeable CPU at zero load, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ClickHouse/ClickHouse/issues/60016&quot;&gt;#60016&lt;/a&gt;. People there report dropping from 40 to 70% CPU down to about 1.5% after disabling the logs and slowing the async-metrics collector. So those two settings, the &lt;code&gt;asynchronous_metrics_update_period_s&lt;/code&gt; line and the &lt;code&gt;remove=&quot;1&quot;&lt;/code&gt; block, pay off twice: less disk and less CPU.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;the-real-lesson-alert-on-the-cause-not-the-effect&quot;&gt;The real lesson: alert on the cause, not the effect&lt;/h3&gt;
&lt;p&gt;Here is the part that stings. I had an alert for &quot;results are being dropped.&quot; I did not have an alert for &quot;the disk is filling up.&quot; So a full disk is a slow, predictable problem that builds over days. But it only reached me as a sudden downstream symptom, after Postgres had already crashed once.&lt;/p&gt;
&lt;p&gt;A downstream alert like &quot;results lost&quot; is not a substitute for watching the resource that actually runs out. I added the missing one: a plain host disk-space alert on &lt;code&gt;node_filesystem_avail_bytes&lt;/code&gt;, firing at 80% and 90% used, well before anything starts failing. That is the alert that should have caught this on day one.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ClickHouse system log tables are unbounded by default and can dwarf your real data. Mine were 11.8 GB against 20 MB of actual data.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;system.text_log&lt;/code&gt; and &lt;code&gt;system.trace_log&lt;/code&gt; are the usual offenders. &lt;code&gt;text_log&lt;/code&gt; is off by default, so check whether something enabled it.&lt;/li&gt;
&lt;li&gt;Cap them in config: &lt;code&gt;remove=&quot;1&quot;&lt;/code&gt; to disable a log, or &lt;code&gt;&amp;lt;ttl&amp;gt;&lt;/code&gt; plus &lt;code&gt;&amp;lt;max_size_rows&amp;gt;&lt;/code&gt; to bound the ones you keep. Truncate to reclaim space right away.&lt;/li&gt;
&lt;li&gt;It is not just disk. The same logs burn CPU at idle on small servers. Disabling them, plus &lt;code&gt;asynchronous_metrics_update_period_s = 60&lt;/code&gt;, took reporters in ClickHouse issue #60016 from 40 to 70% CPU down to about 1.5%.&lt;/li&gt;
&lt;li&gt;Anything that pulls artifacts on a schedule, Docker images in my case, needs matching cleanup or it becomes a slow disk leak.&lt;/li&gt;
&lt;li&gt;Alert on the cause (disk space), not only the effect (dropped writes). The cause gives you days of warning; the effect gives you minutes.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This happened on &lt;a rel=&quot;nofollow&quot; href=&quot;https://uptimepage.dev/blog/clickhouse-system-tables-filled-disk&quot;&gt;Uptimepage&lt;/a&gt;, the uptime monitor I run and dogfood.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22171/clickhouse-system-tables-ate-my-disk-and-the-fix</guid>
<pubDate>Thu, 09 Jul 2026 15:14:44 +0000</pubDate>
</item>
<item>
<title>Postgres vs ClickHouse? I use both. 4 tricks from the split.</title>
<link>https://coderlegion.com/22131/postgres-vs-clickhouse-i-use-both-4-tricks-from-the-split</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; My uptime monitor keeps config and incidents in Postgres and check results in ClickHouse. The split is one rule: does a row ever change? Config gets edited, so it wants Postgres transactions and constraints. A check result is written once and never touched again, so it goes to ClickHouse, where the right codec, a careful sort key, and a per-row TTL make billions of rows cheap. Four tricks and a bonus below, useful even if you only ever run one database.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Every monitor I run writes a row every 20 seconds, from every region, and never stops. One monitor is about 4,300 rows a day, per region. Multiply by every monitor on the platform and the rows only ever go up.&lt;/p&gt;
&lt;p&gt;That stream would slowly crush a normal Postgres table, and watching it is the whole product. So the check results do not live in Postgres. They live in ClickHouse. Everything else, the monitors and incidents and teams, lives in Postgres. The interesting part is the line between them.&lt;/p&gt;
&lt;p&gt;People ask why not one database. &quot;Postgres vs ClickHouse&quot; is the wrong question, because the two are not fighting over the same job. Here is the line I draw, and the one rule under all of it: split your data by how it is written, not by which engine is faster.&lt;/p&gt;
&lt;h3 id=&quot;1-does-the-row-change-that-picks-the-database&quot;&gt;1. Does the row change? That picks the database&lt;/h3&gt;
&lt;p&gt;Forget benchmarks for a second. One question sorts a table into one store or the other: after you write a row, will you ever change it?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;one monitor, two stores:

  you edit a monitor  -&amp;gt;  Postgres   (targets, incidents, team)
                          the row changes, has constraints, lives in a transaction

  a probe checks it   -&amp;gt;  ClickHouse (check_results)
                          one row, written once, never updated
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A monitor is a row that changes. You toggle it off, edit the URL, change the interval. So it lives in Postgres:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE targets (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name          TEXT NOT NULL,
    check_spec    JSONB NOT NULL,
    interval_secs INTEGER NOT NULL CHECK (interval_secs &amp;gt;= 10),
    enabled       BOOLEAN NOT NULL DEFAULT true,
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;CHECK&lt;/code&gt; means a 3-second interval can never reach the table, no matter which part of the app tried to write it. This is Postgres doing the thing it is best at: a small set of rows that must stay correct while many callers change them at once.&lt;/p&gt;
&lt;p&gt;A check result is the opposite. It is written once when a probe finishes, and then it never changes. Nothing ever updates it, so it does not need any of that:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE check_results (
    org_id      UUID,
    target_id   UUID,
    region      LowCardinality(String),
    timestamp   DateTime('UTC') CODEC(DoubleDelta, ZSTD(1)),
    status      Enum8('up' = 1, 'down' = 2, 'degraded' = 3, 'error' = 4),
    duration_ms UInt32 CODEC(T64, ZSTD(1))
) ENGINE = MergeTree
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;updated_at&lt;/code&gt;, no foreign key, no transaction. Just an append-only stream that grows forever. That is the shape ClickHouse is built for and the shape that slowly hurts Postgres.&lt;/p&gt;
&lt;p&gt;Notice the types too. &lt;code&gt;status&lt;/code&gt; is an &lt;code&gt;Enum8&lt;/code&gt;, one byte on disk, not the string &lt;code&gt;&quot;up&quot;&lt;/code&gt;. &lt;code&gt;region&lt;/code&gt; is &lt;code&gt;LowCardinality&lt;/code&gt;, stored once in a dictionary and referenced by a small id instead of repeating the text on every row. On a table that only ever grows, a byte saved per row is a byte saved times billions.&lt;/p&gt;
&lt;h3 id=&quot;2-the-right-codec-turns-a-day-of-timestamps-into-almost-nothing&quot;&gt;2. The right codec turns a day of timestamps into almost nothing&lt;/h3&gt;
&lt;p&gt;Once the results are in a column store, the type on each column is most of the compression, and the default setting wastes a lot of space.&lt;/p&gt;
&lt;p&gt;Look at that timestamp again. A monitor checks every 20 seconds, so for one monitor a day of timestamps is a run of 20, 20, 20, 20, over and over. &lt;code&gt;DoubleDelta&lt;/code&gt; stores the change in the change. For a steady interval the first delta is a constant 20 and the second delta is zero, so each row after the first packs down to about a bit. The codec is not a ClickHouse invention: it comes from Facebook's Gorilla time-series paper, built for exactly this, measurements taken at a steady rate.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;timestamp   DateTime('UTC')  CODEC(DoubleDelta, ZSTD(1)),
duration_ms UInt32           CODEC(T64, ZSTD(1)),
dns_ms      Nullable(UInt16) CODEC(T64, ZSTD(1)),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I also store the timestamp at whole seconds on purpose. The smallest interval is 20 seconds, so no two checks for one monitor ever land in the same second, and the sub-second detail lives in &lt;code&gt;duration_ms&lt;/code&gt; where it belongs. Whole seconds that step by a fixed amount compress far harder than milliseconds would.&lt;/p&gt;
&lt;p&gt;The latency columns get &lt;code&gt;T64&lt;/code&gt; instead, because they are small integers that stay in a small range, a different shape from a steady clock. &lt;code&gt;T64&lt;/code&gt; crops the unused high bits off a block of values, so a &lt;code&gt;UInt32&lt;/code&gt; that never climbs past a few thousand milliseconds stops paying for all 32 bits. That is the trick worth copying, and it is not the specific codec names. It is that a timestamp ticking by a fixed step and a latency staying in a small range are two different shapes, and telling the database which is which does the work.&lt;/p&gt;
&lt;h3 id=&quot;3-put-the-tenant-in-the-sort-key-not-the-partition&quot;&gt;3. Put the tenant in the sort key, not the partition&lt;/h3&gt;
&lt;p&gt;I got this one wrong first, so learn it from my mistake instead of your own.&lt;/p&gt;
&lt;p&gt;This is a multi-tenant app, so every row carries an &lt;code&gt;org_id&lt;/code&gt; and almost every query filters by it. My first schema partitioned by org, one slot per customer, because it felt tidy. ClickHouse turned that into a flood of tiny parts, the merges could not keep up, and startup got slower the more customers I had. I ripped it out. A partition is not a folder for tidiness. It is a physical unit ClickHouse merges and expires, and you want few large ones, not many small ones.&lt;/p&gt;
&lt;p&gt;Here is what it should be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;) ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (org_id, target_id, region, timestamp)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;org_id&lt;/code&gt; leads the &lt;code&gt;ORDER BY&lt;/code&gt;, so a per-org query walks the sorted primary index and reads only that org's slice. But it stays out of &lt;code&gt;PARTITION BY&lt;/code&gt;. The rule I follow now: partition by something low-cardinality that you also delete by, here the day, and put the high-cardinality tenant key in the sort order. Sort key answers &quot;find this org fast&quot;. Partition answers &quot;drop old data cheaply&quot;, which is the next trick.&lt;/p&gt;
&lt;h3 id=&quot;4-make-retention-a-number-in-a-row-not-a-schema-change&quot;&gt;4. Make retention a number in a row, not a schema change&lt;/h3&gt;
&lt;p&gt;Different plans keep history for different lengths of time. The clumsy way is a migration or a cleanup job per plan. The clean way is to make the retention window a column:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ttl_days UInt16 DEFAULT 30 CODEC(ZSTD(1))
) ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (org_id, target_id, region, timestamp)
TTL timestamp + toIntervalDay(ttl_days)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each row carries its own &lt;code&gt;ttl_days&lt;/code&gt;, stamped from the org's plan at the moment it is written. A free plan keeps 30 days, a paid plan keeps more, and changing that needs no &lt;code&gt;ALTER&lt;/code&gt;, no migration, no backfill. The next write just stores a different number. The daily partitions line up with the TTL, so ClickHouse expires old data by dropping whole parts, close to free, and a whole column of the same &lt;code&gt;30&lt;/code&gt; compresses away to nothing. The flexibility costs no space.&lt;/p&gt;
&lt;p&gt;One more piece makes reading that history cheap. A materialized view rolls raw checks into per-minute and per-hour summaries as they land:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE MATERIALIZED VIEW check_results_1m
ENGINE = AggregatingMergeTree
ORDER BY (org_id, target_id, region, minute)
AS SELECT
    org_id, target_id, region,
    toStartOfMinute(timestamp)  AS minute,
    countState()                AS total_checks,
    countIfState(status = 'up') AS up_checks,
    avgState(duration_ms)       AS avg_duration_ms
FROM check_results
GROUP BY org_id, target_id, region, minute;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So a dashboard showing 30 days across a thousand monitors reads a few thousand minute-buckets, not millions of raw rows. Recent views read the minute rollup, long history reads an hour rollup, and the raw rows underneath expire on their own TTL. The firehose is there when you need to drill into one bad minute, and left alone the rest of the time.&lt;/p&gt;
&lt;h3 id=&quot;one-more-because-everyone-hits-it-make-inserts-idempotent&quot;&gt;One more, because everyone hits it: make inserts idempotent&lt;/h3&gt;
&lt;p&gt;Here is the trick I wish someone had told me first. My agents batch check results and send them to ClickHouse. A network blip after the server commits but before my side gets the ack means the batch retries and sends the exact same block again. Without protection, that double-counts every row in it, and your uptime numbers quietly drift.&lt;/p&gt;
&lt;p&gt;ClickHouse has a fix built in, but for a plain (non-Replicated) MergeTree it is off until you turn it on:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SETTINGS index_granularity = 8192,
         non_replicated_deduplication_window = 1000;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The server hashes each inserted block and remembers the last 1,000 hashes. A retry sends an identical block, the hash matches, and the server drops it instead of appending it again. The retry becomes safe to do blindly, so the client stays simple: send, and if unsure, send again. Make the window bigger than the most blocks a single retry could resend, and you stop having to worry about duplicate writes.&lt;/p&gt;
&lt;h3 id=&quot;so-one-database-or-two-&quot;&gt;So, one database or two?&lt;/h3&gt;
&lt;p&gt;For almost every app, one. If your data fits in Postgres and stays fast, a second database is a cost you should not pay: two schemas, two clients, two things to back up and think about.&lt;/p&gt;
&lt;p&gt;Reach for the second store only when one table stops looking like the rest of your tables. For me that table was &lt;code&gt;check_results&lt;/code&gt;. It is written once and never edited, it grows without end, and every question I ask it is a summary over a time range. That is a different shape from my monitors and my incidents, so it wanted a different database.&lt;/p&gt;
&lt;p&gt;The rule I would give my past self: do not split by &quot;which database is faster&quot;. Split by how the data is written. Rows that change and must stay correct want Postgres. An append-only stream you only ever summarize wants a column store like ClickHouse. Most of the tricks above are that one idea pushed down into the schema.&lt;/p&gt;
&lt;p&gt;Uptimepage is open source, AGPL-3.0, and both schemas are in the repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/uptimepage/uptimepage&quot;&gt;github.com/uptimepage/uptimepage&lt;/a&gt;. The probe that writes those rows is &lt;a rel=&quot;nofollow&quot; href=&quot;https://uptimepage.dev/blog/http-prober-in-rust-no-reqwest&quot;&gt;its own post&lt;/a&gt;, and the wider build story, one binary and two databases, is &lt;a rel=&quot;nofollow&quot; href=&quot;https://uptimepage.dev/blog/building-an-uptime-monitor-in-rust&quot;&gt;here&lt;/a&gt;. Or &lt;a rel=&quot;nofollow&quot; href=&quot;https://uptimepage.dev&quot;&gt;start free on the hosted tier&lt;/a&gt; and point a check at something.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22131/postgres-vs-clickhouse-i-use-both-4-tricks-from-the-split</guid>
<pubDate>Thu, 09 Jul 2026 05:57:37 +0000</pubDate>
</item>
<item>
<title>php_clickhouse 0.8.1: Three Releases Later, Stable</title>
<link>https://coderlegion.com/17185/phpclickhouse-0-8-1-three-releases-later-stable</link>
<description>&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://ilia.ws/blog/php-clickhouse-a-native-clickhouse-client-for-php-picking-up-where-seasclick-left-off&quot;&gt;launch post for php_clickhouse 0.6.0&lt;/a&gt; covered the framing: native binary protocol, soft fork of the stalled SeasClick, modern ClickHouse types, 30-40% faster than HTTP at high throughput. That post landed April 25, 2026. Today (May 1, 2026) the current tag is 0.8.1, and I'm calling the extension stable.&lt;/p&gt;
&lt;p&gt;The six days in between were a focused quality cycle, not a feature sprint. Three buckets:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Performance.&lt;/strong&gt; Insert and write paths build native ClickHouse columns one at a time directly from row-major input. Peak intermediate PHP memory dropped from &lt;code&gt;N_rows × N_cols&lt;/code&gt; zvals to one column.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security.&lt;/strong&gt; Strict full-consumption parsers across &lt;code&gt;Map&lt;/code&gt;, narrow-int, Int128 / UInt128, geo, DateTime64, Time64, hex literals, and typed parameters. Wrong-type input throws instead of corrupting memory or coercing silently to zero. Recursive type-conversion gained a depth cap so adversarial server schemas can't blow the stack.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stability.&lt;/strong&gt; Per-Client state moved from file-scope &lt;code&gt;std::map&lt;/code&gt; banks onto the &lt;code&gt;zend_object&lt;/code&gt; itself. Unblocks ZTS, plugs leaks on bailout, fixes a refcount bug on the progress callback. Insert path recovers the native handle on every server-side rejection point so a thrown insert no longer wedges the connection.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Three releases (0.7.0, 0.8.0, 0.8.1) closed the API gap with the most-used HTTP client, refactored the extension's state model, hardened the insert surface, and surfaced one upstream UB fix that has since merged into clickhouse-cpp.&lt;/p&gt;
&lt;p&gt;Here's the work.&lt;/p&gt;
&lt;h3 id=&quot;0-7-0-closing-the-ergonomics-gap-with-smi2-phpclickhouse&quot;&gt;0.7.0: Closing the Ergonomics Gap with smi2/phpClickHouse&lt;/h3&gt;
&lt;p&gt;The native binary protocol gives you 30-40% throughput. Most teams won't trade a familiar API for that, so the native client has to match the ergonomic surface of the most-used PHP HTTP client (&lt;code&gt;smi2/phpClickHouse&lt;/code&gt;). 0.7.0 is the release that actually does that.&lt;/p&gt;
&lt;p&gt;What landed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;setSettings(array)&lt;/code&gt; for client-wide ClickHouse settings (&lt;code&gt;max_execution_time&lt;/code&gt;, &lt;code&gt;max_memory_usage&lt;/code&gt;, &lt;code&gt;async_insert&lt;/code&gt;). Per-call settings as a 5th array argument on &lt;code&gt;select()&lt;/code&gt; / &lt;code&gt;insert()&lt;/code&gt; / &lt;code&gt;execute()&lt;/code&gt; / &lt;code&gt;writeStart()&lt;/code&gt;. Per-call overrides global.&lt;/li&gt;
&lt;li&gt;Server-side typed parameters via the &lt;code&gt;{name:Type}&lt;/code&gt; placeholder syntax. Routed through &lt;code&gt;Query::SetParam&lt;/code&gt; so the server quotes and parses according to the declared type. Plain &lt;code&gt;{name}&lt;/code&gt; placeholders keep their existing client-side identifier-substitution behavior. Arrays format as ClickHouse array literals so &lt;code&gt;Array(UInt32)&lt;/code&gt;, &lt;code&gt;Array(String)&lt;/code&gt; round-trip cleanly.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setProgressCallback(?callable)&lt;/code&gt; invoked for every &lt;code&gt;Progress&lt;/code&gt; packet during a query (&lt;code&gt;rows&lt;/code&gt;, &lt;code&gt;bytes&lt;/code&gt;, &lt;code&gt;total_rows&lt;/code&gt;, &lt;code&gt;written_rows&lt;/code&gt;, &lt;code&gt;written_bytes&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getStatistics()&lt;/code&gt; returning &lt;code&gt;rows_read&lt;/code&gt;, &lt;code&gt;bytes_read&lt;/code&gt;, &lt;code&gt;total_rows&lt;/code&gt;, &lt;code&gt;written_rows&lt;/code&gt;, &lt;code&gt;written_bytes&lt;/code&gt;, &lt;code&gt;blocks&lt;/code&gt;, &lt;code&gt;rows_before_limit&lt;/code&gt;, &lt;code&gt;applied_limit&lt;/code&gt;, &lt;code&gt;elapsed_ms&lt;/code&gt; from the last completed query. Reset at the start of each query.&lt;/li&gt;
&lt;li&gt;Structured &lt;code&gt;ClickHouseException&lt;/code&gt;: &lt;code&gt;server_code&lt;/code&gt; (e.g. 159 for &lt;code&gt;TIMEOUT_EXCEEDED&lt;/code&gt;), &lt;code&gt;server_name&lt;/code&gt; (&lt;code&gt;DB::Exception&lt;/code&gt;), &lt;code&gt;query_id&lt;/code&gt;. Populated on server errors and on any throw with a query-id context.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;insertAssoc(table, rows)&lt;/code&gt; derives the column list from the keys of the first row.&lt;/li&gt;
&lt;li&gt;SQL helpers: &lt;code&gt;databaseSize()&lt;/code&gt;, &lt;code&gt;tablesSize()&lt;/code&gt;, &lt;code&gt;partitions()&lt;/code&gt;, &lt;code&gt;showTables()&lt;/code&gt;, &lt;code&gt;showCreateTable()&lt;/code&gt;, &lt;code&gt;getServerUptime()&lt;/code&gt;. Each validates identifiers against the safe-character set.&lt;/li&gt;
&lt;li&gt;Sub-second timeouts via &lt;code&gt;connect_timeout_ms&lt;/code&gt;, &lt;code&gt;receive_timeout_ms&lt;/code&gt;, &lt;code&gt;send_timeout_ms&lt;/code&gt; config keys. Override the existing seconds-based keys when present.&lt;/li&gt;
&lt;li&gt;Per-client query log accumulator: &lt;code&gt;enableLogQueries(bool)&lt;/code&gt; toggles, &lt;code&gt;getLogQueries()&lt;/code&gt; returns and clears. Each entry carries &lt;code&gt;sql&lt;/code&gt;, &lt;code&gt;query_id&lt;/code&gt;, &lt;code&gt;elapsed_ms&lt;/code&gt;, &lt;code&gt;rows_read&lt;/code&gt;, &lt;code&gt;bytes_read&lt;/code&gt;, &lt;code&gt;error_code&lt;/code&gt;, &lt;code&gt;error_message&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The other under-the-hood change in 0.7.0 was migrating to a stub-driven arginfo workflow (&lt;code&gt;clickhouse.stub.php&lt;/code&gt; → generated &lt;code&gt;clickhouse_arginfo.h&lt;/code&gt;). Method parameter and return types are now declared at the engine boundary and visible to Reflection, IDEs, and static analyzers. Behavior is unchanged for correctly-typed callers; wrong-type callers now hit ZPP at the boundary instead of a custom thrown exception inside the method body.&lt;/p&gt;
&lt;p&gt;None of 0.7.0 is novel on its own. The point is that without these the native client made you pay an ergonomics tax to get the speed. 0.7.0 settles that tab.&lt;/p&gt;
&lt;h3 id=&quot;0-8-0-per-object-state-zts-and-streaming&quot;&gt;0.8.0: Per-Object State, ZTS, and Streaming&lt;/h3&gt;
&lt;p&gt;The 0.6.0 / 0.7.0 surface stored per-Client state in seven file-scope &lt;code&gt;std::map&amp;lt;int, ...&amp;gt;&lt;/code&gt; banks keyed on &lt;code&gt;Z_OBJ_HANDLE&lt;/code&gt;: the &lt;code&gt;Client*&lt;/code&gt;, the in-flight insert Block, the ClientStats, the global settings, the progress and profile callbacks, the log toggle, the query log buffer.&lt;/p&gt;
&lt;p&gt;That works, and it has three durability problems baked in:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;No ZTS support.&lt;/strong&gt; Threaded SAPIs share that file-scope state across threads. The 0.6.0 code gated MINIT with a hard error when &lt;code&gt;--enable-zts&lt;/code&gt; was on. ClickHouse from RoadRunner / FrankenPHP / Swoole / php-pm was a non-starter.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leaks on bailout.&lt;/strong&gt; PHP's userspace &lt;code&gt;__destruct&lt;/code&gt; doesn't run on fatal errors, so the map entries (and the underlying &lt;code&gt;Client*&lt;/code&gt; and any half-open insert stream) leaked.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Refcount bug on the progress callback.&lt;/strong&gt; A struct copy of the registered callable went stale when the calling scope went out of scope, and the next progress packet hit a freed zval.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;0.8.0 moved the per-Client state onto the &lt;code&gt;zend_object&lt;/code&gt; itself via custom &lt;code&gt;create_object&lt;/code&gt; / &lt;code&gt;free_obj&lt;/code&gt; handlers. The seven file-scope maps disappear entirely. ZTS gating at MINIT was deleted in the same release.&lt;/p&gt;
&lt;p&gt;The refactor unblocks three things at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Threaded SAPIs.&lt;/strong&gt; No global state to thread-isolate, so ZTS Linux is a first-class target now. CI grew a &lt;code&gt;linux-zts&lt;/code&gt; job (PHP 8.4 ZTS built from source).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cleanup on bailout.&lt;/strong&gt; &lt;code&gt;free_obj&lt;/code&gt; runs unconditionally, including on fatal errors. The &lt;code&gt;Client*&lt;/code&gt; and any half-open insert stream get torn down properly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The progress-callback fix lands.&lt;/strong&gt; &lt;code&gt;setProgressCallback&lt;/code&gt; now uses &lt;code&gt;ZVAL_COPY&lt;/code&gt; instead of a struct copy, so the callable doesn't get freed out from under the next packet.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A Windows &lt;code&gt;config.w32&lt;/code&gt; shipped in the same release, rewritten from a 9-line warning stub to a full Windows build script that mirrors &lt;code&gt;config.m4&lt;/code&gt;'s source list and flags. Optional &lt;code&gt;--enable-clickhouse-openssl&lt;/code&gt; plumbing is mirrored via &lt;code&gt;CHECK_LIB(&quot;libssl.lib&quot;, ...)&lt;/code&gt;. CI exercises Windows as a build + extension-load smoke test (no live ClickHouse on Windows yet).&lt;/p&gt;
&lt;h4 id=&quot;streaming-reads&quot;&gt;Streaming reads&lt;/h4&gt;
&lt;p&gt;0.8.0 introduced two new read paths for result sets that don't fit comfortably in a single PHP array:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$it = $ch-&amp;gt;selectStream(&quot;SELECT id, payload FROM events WHERE day = today()&quot;);
foreach ($it as $row) {
    process($row);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;selectStream()&lt;/code&gt; returns a &lt;code&gt;ClickHouseRowIterator&lt;/code&gt; (&lt;code&gt;Iterator&lt;/code&gt; + &lt;code&gt;Countable&lt;/code&gt;) that walks blocks lazily. The iterator survives &lt;code&gt;unset($client)&lt;/code&gt; because blocks own their column data via &lt;code&gt;shared_ptr&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For unbounded streams where you don't want to count or rewind:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$ch-&amp;gt;selectStreamCallback(
    &quot;SELECT id, body FROM events_unbounded&quot;,
    fn(array $row) =&amp;gt; writeToS3($row),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The callback fires once per row as blocks arrive, never accumulating the full result.&lt;/p&gt;
&lt;p&gt;The plain &lt;code&gt;select()&lt;/code&gt; path is unchanged and remains the faster choice when you actually want a full PHP array. The streaming variants exist for the row-millions case where you don't.&lt;/p&gt;
&lt;h4 id=&quot;geo-lowcardinality-nullable-and-the-map-matrix&quot;&gt;Geo, LowCardinality(Nullable), and the Map matrix&lt;/h4&gt;
&lt;p&gt;The type surface expanded too:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Geo types Point, Ring, Polygon, MultiPolygon round-trip via &lt;code&gt;ColumnGeo&lt;/code&gt;. Point as &lt;code&gt;[Float64, Float64]&lt;/code&gt;, the others as nested arrays.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LowCardinality(Nullable(String))&lt;/code&gt; and &lt;code&gt;LowCardinality(Nullable(FixedString))&lt;/code&gt; round-trip on read and write.&lt;/li&gt;
&lt;li&gt;The insert path now accepts any &lt;code&gt;Map(K, V)&lt;/code&gt; over scalar K and V (String, all signed/unsigned integer widths, Float32/64, UUID) plus &lt;code&gt;LowCardinality(String)&lt;/code&gt; keys and values. The read path mirrors the same matrix except for &lt;code&gt;LowCardinality&lt;/code&gt; keys (vendor gap). Previously only five hardcoded combinations worked.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SimpleAggregateFunction(f, T)&lt;/code&gt; reads transparently as &lt;code&gt;T&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Geo support unblocks one of the two large reasons people stayed on the HTTP client. The other was streaming.&lt;/p&gt;
&lt;h4 id=&quot;other-0-8-0-surfaces-worth-naming&quot;&gt;Other 0.8.0 surfaces worth naming&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;selectStatement()&lt;/code&gt; returns a &lt;code&gt;ClickHouseStatement&lt;/code&gt; result wrapper: &lt;code&gt;Iterator&lt;/code&gt;, &lt;code&gt;Countable&lt;/code&gt;, &lt;code&gt;ArrayAccess&lt;/code&gt;, &lt;code&gt;JsonSerializable&lt;/code&gt;, plus &lt;code&gt;fetchOne()&lt;/code&gt; / &lt;code&gt;fetchKeyPair()&lt;/code&gt; / &lt;code&gt;fetchColumn()&lt;/code&gt; / &lt;code&gt;toArray()&lt;/code&gt; / &lt;code&gt;statistics()&lt;/code&gt;. Read-only (&lt;code&gt;offsetSet&lt;/code&gt; / &lt;code&gt;offsetUnset&lt;/code&gt; throw). Carries a per-call stats snapshot so it survives the client running other queries afterwards.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setVerbose(bool|callable)&lt;/code&gt; for protocol-level lifecycle tracing. Pass &lt;code&gt;true&lt;/code&gt; for JSON lines on STDERR, or a callable invoked with &lt;code&gt;($eventName, $context)&lt;/code&gt;. Events: &lt;code&gt;select_start&lt;/code&gt;, &lt;code&gt;data_block&lt;/code&gt;, &lt;code&gt;select_finish&lt;/code&gt;, &lt;code&gt;execute_start&lt;/code&gt;, &lt;code&gt;execute_finish&lt;/code&gt;, &lt;code&gt;server_exception&lt;/code&gt;. No-op when off, so the hot path stays cheap on production.&lt;/li&gt;
&lt;li&gt;DDL helpers: &lt;code&gt;isExists()&lt;/code&gt;, &lt;code&gt;showDatabases()&lt;/code&gt;, &lt;code&gt;showProcesslist()&lt;/code&gt;, &lt;code&gt;getServerVersion()&lt;/code&gt;, &lt;code&gt;tableSize()&lt;/code&gt;, &lt;code&gt;truncateTable()&lt;/code&gt;, &lt;code&gt;dropPartition()&lt;/code&gt;. All identifier args validated; &lt;code&gt;dropPartition&lt;/code&gt; SQL-escapes the partition value.&lt;/li&gt;
&lt;li&gt;Client introspection: &lt;code&gt;resetConnection()&lt;/code&gt;, &lt;code&gt;getServerInfo()&lt;/code&gt; (name, version, revision, timezone, display_name), &lt;code&gt;getCurrentEndpoint()&lt;/code&gt; (host/port of the active endpoint when an endpoints[] pool is in use), &lt;code&gt;setProfileCallback()&lt;/code&gt;, &lt;code&gt;ping_before_query&lt;/code&gt; config key.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;query_id&lt;/code&gt; echoed through &lt;code&gt;getStatistics()&lt;/code&gt; so callers can correlate a stats snapshot to a server-side query in &lt;code&gt;system.query_log&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;smi2-style sugar: &lt;code&gt;setSettings()&lt;/code&gt; returns &lt;code&gt;$this&lt;/code&gt; for chaining, &lt;code&gt;setSetting(key, value)&lt;/code&gt; for the single-key form, &lt;code&gt;setDatabase(string)&lt;/code&gt; issues &lt;code&gt;USE&lt;/code&gt; and updates the cached default used by &lt;code&gt;databaseSize()&lt;/code&gt; / &lt;code&gt;showTables()&lt;/code&gt;, getter aliases (&lt;code&gt;getServerCode()&lt;/code&gt;, &lt;code&gt;getServerName()&lt;/code&gt;, &lt;code&gt;getQueryId()&lt;/code&gt;) on &lt;code&gt;ClickHouseException&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;ipv4-ipv6-crash-fixed&quot;&gt;IPv4 / IPv6 crash, fixed&lt;/h4&gt;
&lt;p&gt;This one's worth calling out as a bug-of-the-release. clickhouse-cpp v2.6.1 made &lt;code&gt;ColumnIPv4&lt;/code&gt; / &lt;code&gt;ColumnIPv6&lt;/code&gt; siblings of (not subclasses of) &lt;code&gt;ColumnUInt32&lt;/code&gt; / &lt;code&gt;ColumnFixedString&lt;/code&gt;. The 0.6.0 / 0.7.0 read paths were doing &lt;code&gt;As&amp;lt;ColumnUInt32&amp;gt;()&lt;/code&gt; / &lt;code&gt;As&amp;lt;ColumnFixedString&amp;gt;()&lt;/code&gt; on IP columns, which now returned null instead of dispatching. The next dereference segfaulted the worker.&lt;/p&gt;
&lt;p&gt;Fixed by switching to &lt;code&gt;ColumnIPv*::AsString(row)&lt;/code&gt; for canonical dotted-quad / &lt;code&gt;::1&lt;/code&gt; form. If you hit a crash on IP column reads pre-0.8.0, this is why.&lt;/p&gt;
&lt;h4 id=&quot;distribution-pre-built-binaries-via-pie&quot;&gt;Distribution: pre-built binaries via PIE&lt;/h4&gt;
&lt;p&gt;Binaries for Linux glibc (x86_64 + arm64) and macOS (x86_64 + arm64) are now available. On a supported platform the install collapses to one line:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pie install iliaal/php_clickhouse
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No vendored clickhouse-cpp build, no abseil compile, no five-minute &lt;code&gt;make&lt;/code&gt;. TLS still requires the source build (&lt;code&gt;pie install iliaal/php_clickhouse --enable-clickhouse-openssl&lt;/code&gt;), but that's a smaller set of users.&lt;/p&gt;
&lt;h3 id=&quot;0-8-1-the-insert-path-that-recovers&quot;&gt;0.8.1: The Insert Path That Recovers&lt;/h3&gt;
&lt;p&gt;0.8.0 was the architecture release. 0.8.1 was the hardening pass: nine rounds of reviewer-driven fixes, mostly on the insert and write surface plus the type-conversion boundary.&lt;/p&gt;
&lt;p&gt;The headline bug:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ClickHouseException: cannot execute query while inserting
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a server-side insert rejection (missing table, bad column, CHECK constraint, schema drift) threw out of &lt;code&gt;BeginInsert&lt;/code&gt; / &lt;code&gt;SendInsertBlock&lt;/code&gt; / &lt;code&gt;EndInsert&lt;/code&gt;, the vendored client's &lt;code&gt;inserting_&lt;/code&gt; flag stayed set. Subsequent &lt;code&gt;select&lt;/code&gt; / &lt;code&gt;execute&lt;/code&gt; on the same handle threw the message above until the caller manually called &lt;code&gt;resetConnection()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;0.8.1 wraps every server-side rejection point in a connection-reset-then-rethrow. Same handle stays usable.&lt;/p&gt;
&lt;p&gt;Destructor cleanup mirrors the same dirty/clean recovery split: an in-flight streaming insert with sent blocks is dropped via &lt;code&gt;ResetConnection&lt;/code&gt; on &lt;code&gt;unset()&lt;/code&gt; rather than committed via &lt;code&gt;EndInsert&lt;/code&gt;. Clean sessions still &lt;code&gt;EndInsert&lt;/code&gt;. Avoids partial commits on script bailout.&lt;/p&gt;
&lt;h4 id=&quot;memory-column-at-a-time-insert&quot;&gt;Memory: column-at-a-time insert&lt;/h4&gt;
&lt;p&gt;Pre-0.8.1, &lt;code&gt;insert()&lt;/code&gt; and &lt;code&gt;write()&lt;/code&gt; materialized a full column-major PHP zval matrix from the user's row-major input before building the native ClickHouse columns. For a 1M-row × 30-column insert that's 30M zvals sitting in PHP memory while the column build runs.&lt;/p&gt;
&lt;p&gt;0.8.1 builds native columns one at a time directly from the row-major input. Peak intermediate PHP memory drops from &lt;code&gt;N_rows × N_cols&lt;/code&gt; to one column.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;insertAssoc()&lt;/code&gt; benefited from the same change: no more positional copy of input rows. The column gatherer reads each column directly from the original associative rows, and key validation uses &lt;code&gt;zend_hash_exists&lt;/code&gt; against the first row's HashTable instead of allocating a new &lt;code&gt;std::string&lt;/code&gt; for every row key.&lt;/p&gt;
&lt;h4 id=&quot;strict-parsers-across-the-type-surface&quot;&gt;Strict parsers across the type surface&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Map&lt;/code&gt;, narrow-int (Int8 / Int16 / Int32 / their unsigned siblings), Int128 / UInt128, geo, DateTime64, Time64 insert paths now use full-consumption strict parsers. Non-numeric strings, fractional doubles, non-finite floats, and out-of-range values throw instead of silently coercing to 0 / 0.0 inside the column.&lt;/p&gt;
&lt;p&gt;UInt64 inserts gained a shared &lt;code&gt;strict_zval_u64&lt;/code&gt; parser that accepts decimal and hex strings above &lt;code&gt;ZEND_LONG_MAX&lt;/code&gt; on both the scalar and &lt;code&gt;Map(*, UInt64)&lt;/code&gt; paths. Reads continue to surface upper-half values as decimal strings.&lt;/p&gt;
&lt;p&gt;The class of bug strict parsing eliminates is the worst kind of insert bug: the string &lt;code&gt;&quot;foo&quot;&lt;/code&gt; lands in an &lt;code&gt;Int32&lt;/code&gt; column as &lt;code&gt;0&lt;/code&gt;, no error, no audit trail. Now it throws.&lt;/p&gt;
&lt;h4 id=&quot;validation-and-reentry&quot;&gt;Validation and reentry&lt;/h4&gt;
&lt;p&gt;A few smaller fixes worth naming:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;write()&lt;/code&gt; rejects rows narrower or wider than the &lt;code&gt;writeStart&lt;/code&gt; column count. The previous path took the first row's element count as authoritative, so &lt;code&gt;[1]&lt;/code&gt; against &lt;code&gt;writeStart(t, ['a','b'])&lt;/code&gt; landed &lt;code&gt;1&lt;/code&gt; into column &lt;code&gt;a&lt;/code&gt; with &lt;code&gt;b&lt;/code&gt; defaulted server-side.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;insert()&lt;/code&gt; rejects rows with extra positional or named cells. A row like &lt;code&gt;[1, 99]&lt;/code&gt; against a single-column table previously landed as &lt;code&gt;1&lt;/code&gt; with &lt;code&gt;99&lt;/code&gt; lost.&lt;/li&gt;
&lt;li&gt;A failed later &lt;code&gt;write()&lt;/code&gt; no longer commits previously sent blocks. The catch path tracks whether any block has been sent in the current &lt;code&gt;writeStart()&lt;/code&gt; session and chooses &lt;code&gt;ResetConnection&lt;/code&gt; (discard) over &lt;code&gt;EndInsert&lt;/code&gt; (commit) on a dirty session.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;insertAssoc()&lt;/code&gt; rejects integer-keyed later rows and any key-set drift from the first row. The first row defines the column set; every later row must match.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Enum8&lt;/code&gt; / &lt;code&gt;Enum16&lt;/code&gt; inserts reject undeclared integers, NULL on non-Nullable columns, and unknown string names.&lt;/li&gt;
&lt;li&gt;Single-token placeholder validator: &lt;code&gt;{name}&lt;/code&gt; placeholders accept exactly one identifier and reject comma-separated lists. Comma-list callers must use array form.&lt;/li&gt;
&lt;li&gt;Same-client reentry guard: a userland progress / profile callback that fires another query on the same handle now throws cleanly instead of crashing the worker on the next &lt;code&gt;ReceiveData&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Recursive type-conversion depth cap (32) keeps deeply nested structures (&lt;code&gt;Array(Array(...))&lt;/code&gt;, &lt;code&gt;Map(K, Tuple(...))&lt;/code&gt;) from blowing the stack.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;23 new PHPTs (072–094) pin all of the above.&lt;/p&gt;
&lt;h3 id=&quot;upstream-one-fix-merged-back-to-clickhouse-cpp&quot;&gt;Upstream: One Fix Merged Back to clickhouse-cpp&lt;/h3&gt;
&lt;p&gt;The ASan job added in 0.8.0 caught a latent UB in the vendored library that nobody had been hitting in production, but UBSan flagged on every empty &lt;code&gt;LowCardinality(String)&lt;/code&gt; value:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;runtime error: null pointer passed as argument 2,
  which is declared to never be null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ColumnStringBlock::AppendUnsafe&lt;/code&gt; was calling &lt;code&gt;memcpy(pos, str.data(), str.size())&lt;/code&gt; unconditionally. When &lt;code&gt;str&lt;/code&gt; was constructed from an empty &lt;code&gt;std::string&lt;/code&gt;, &lt;code&gt;str.data()&lt;/code&gt; is allowed to be &lt;code&gt;NULL&lt;/code&gt;, and libc declares &lt;code&gt;memcpy&lt;/code&gt;'s second argument with &lt;code&gt;__attribute__((nonnull))&lt;/code&gt; regardless of the size. Every libc no-ops &lt;code&gt;memcpy(_, NULL, 0)&lt;/code&gt; in practice, so the bug was benign on real workloads, but the false-positive UBSan trip was noising the extension's ASan job and obscuring real findings.&lt;/p&gt;
&lt;p&gt;Patch: guard the &lt;code&gt;memcpy&lt;/code&gt; with &lt;code&gt;if (str.size() &amp;gt; 0)&lt;/code&gt;. Submitted upstream as &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ClickHouse/clickhouse-cpp/pull/489&quot;&gt;clickhouse-cpp#489&lt;/a&gt;, merged 2026-04-27. The local patch in &lt;code&gt;lib/clickhouse-cpp/LOCAL_PATCHES.md&lt;/code&gt; will drop the next time the vendored library bumps.&lt;/p&gt;
&lt;h3 id=&quot;what-s-still-missing&quot;&gt;What's Still Missing&lt;/h3&gt;
&lt;p&gt;Two limitations carry forward from clickhouse-cpp v2.6.1:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SELECT ... WITH TOTALS&lt;/code&gt; and &lt;code&gt;SETTINGS extremes=1&lt;/code&gt; throw &lt;code&gt;unimplemented 7&lt;/code&gt; from the cpp layer. The vendored library does not dispatch the Totals / Extremes packet types (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ClickHouse/clickhouse-cpp/issues/297&quot;&gt;upstream issue #297&lt;/a&gt;). &lt;code&gt;getTotals()&lt;/code&gt; / &lt;code&gt;getExtremes()&lt;/code&gt; are deferred.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Map(LowCardinality(K), V)&lt;/code&gt; reads are not yet decoded by the vendored library (writes succeed). &lt;code&gt;showProcesslist()&lt;/code&gt; selects a fixed projection of standard columns to avoid the unsupported Map columns (&lt;code&gt;ProfileEvents&lt;/code&gt;, &lt;code&gt;Settings&lt;/code&gt;, &lt;code&gt;used_*&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If either blocks your workload, file an issue at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_clickhouse&quot;&gt;github.com/iliaal/php_clickhouse&lt;/a&gt; with the schema and a minimal repro. Both are upstream and tracked.&lt;/p&gt;
&lt;p&gt;The repo is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_clickhouse&quot;&gt;github.com/iliaal/php_clickhouse&lt;/a&gt;. Install via PIE: &lt;code&gt;pie install iliaal/php_clickhouse&lt;/code&gt; (add &lt;code&gt;--enable-clickhouse-openssl&lt;/code&gt; for TLS). The original launch post that framed the fork story sits at &lt;a rel=&quot;nofollow&quot; href=&quot;https://ilia.ws/blog/php-clickhouse-a-native-clickhouse-client-for-php-picking-up-where-seasclick-left-off&quot;&gt;ilia.ws&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/17185/phpclickhouse-0-8-1-three-releases-later-stable</guid>
<pubDate>Sat, 09 May 2026 17:17:45 +0000</pubDate>
</item>
</channel>
</rss>