An online store publishes a record whenever a product's price changes. One application needs to examine each price update. Another needs to answer a different question: what is the current price of each product?
Both applications can read the same Kafka topic, but they interpret its records differently. The first treats each record as an item to process. The second treats it as a change to a row that the product ID identifies.
Kafka Streams expresses these views through KStream and KTable. This chapter explains their record semantics, how updates and deletions work, and why choosing between them changes the behavior of a processing pipeline.
A KStream models a stream of individual records. Receiving another record with an existing key does not replace the earlier record. Each remains a separate input to the computation.
A KTable models a table that changes over time. The record key identifies a row, and a non-null value supplies that row's new value. A record with an existing key updates that row; a new key creates a row. An upsert means inserting or updating a row.
Suppose catalog.prices contains these two records in order:
The values are complete prices: $129.00 followed by $119.00. They are not amounts to add to a balance.
A stream processes both records. A table first holds product-318 → 12900, then replaces that row's value with 11900. After the table applies both records, the table has one row for this product.
The diagram shows these two interpretations of the same input. The stream path shows the records the application processes, not a collection that KStream automatically stores in memory.
Neither abstraction changes what the broker originally stored. Kafka still has a log of records, subject to the topic's cleanup policy. The abstraction determines what those records mean inside the processing application.
Also, reading a topic as a stream does not guarantee that every historical update is still available. If Kafka has already removed an old record, a new stream reader cannot recover it from that topic.
To see how row updates accumulate, assume an initially empty price table and the following input. All records shown are in partition 0; offsets identify their order in that partition. The prices are in USD cents, and each value replaces the previous price in full.
A KStream reading these records receives five independent inputs. A KTable applies them as changes and ends with two current rows. The input record count and current row count answer different questions.
The table is a continuously updated view of the data it has processed. If its application has reached only offset 42, it still shows the $119.00 price for product-318, even if the broker already holds offsets 43 and 44. “Current” means current at the table's processing progress.
The key must identify the entity whose value the record replaces. If the producer uses a unique event ID for every price update, the table creates a different row for each event. It cannot infer that several event IDs refer to the same product.
Related records also need consistent partitioning by that key. The single partition makes this trace easy to follow; a real price topic can spread different products across several partitions. Keep each product's updates together so they reach the task maintaining that product's row.
For a KTable, a record with a non-null key and a null value is a tombstone. It deletes the row the key identifies. In our trace, offset 43 removes product-318 from the price table.
The deletion does not create a row whose price is null. The row is absent. A later non-null value for the same key can create it again, as offset 44 does.
The diagram follows just this product through the relevant state changes. The labels describe the records the table applies.
Here, the null is the actual null Kafka record value. The text "null" is a string, and a JSON object such as {"price": null} is a non-null record value. Neither automatically acts as a Kafka Streams tombstone.
Deletion meaning also comes from the data contract. Removing a price row might mean the product no longer has a published price. It does not necessarily mean every system has deleted the entire product.
A KStream has no built-in table row to delete. A null-valued record is still an input whose handling depends on the operation. A filter can inspect it, while many aggregations skip null values. Converting the records to a table gives those null values deletion semantics.
Null keys are different from null values. A table needs a key to identify the affected row, so Kafka Streams drops null-key records when constructing a KTable. A stream can carry null keys, although operations that group or join by key impose their own requirements. Model the key deliberately rather than relying on an operator to repair missing identity.
The price example works as a table because every non-null record contains the complete replacement value. This distinction matters when values describe changes rather than current state.
Consider an inventory topic keyed by product. A value of 5 might mean either “five units are available” or “the warehouse added five units.” Those contracts require different processing.
If the records say that the available stock is first 20 and then 5, a table should end at 5. If the records say to add 20 units and then add 5 units, the application needs to accumulate the changes to produce 25. Simply reading that second contract as a table would leave the row at 5 and lose the intended calculation.
The same issue appears with partial objects. A customer update containing only a new address does not automatically merge into the customer's previous full value. A source table replaces the value it receives. Field-level merging requires explicit processing or a producer that publishes complete replacement values.
This is why both streams and tables often appear in one application. A stream of inventory movements can feed an aggregation that maintains a table of stock totals. The stream describes individual movements; the table describes the accumulated result.
KStream therefore does not mean “stateless application.” A stream aggregation needs state and commonly produces a KTable. The distinction is the meaning of records, not whether the application ever remembers information.
The method name filter exists on both abstractions, but each filter produces a result that matches its stream or table meaning.
Suppose we want prices below $120.00, which means values below 12000. For this separate example, product-318 first has a price of 11900, then changes to 14900.
A stream filter forwards the 11900 record and drops the 14900 record. It produces a stream of qualifying price records. Nothing about dropping the second record retracts the first output.
A table filter maintains a table of products whose current price qualifies. It first includes product-318 = 11900. When the price becomes 14900, it must remove that product from the filtered table. Otherwise, the result would incorrectly claim that the product still costs less than $120.00.
The diagram shows the outcome after the application processes both updates. The table path needs a deletion to keep a downstream view correct.
This gives us two useful but different outputs: qualifying updates and a maintained view of currently qualifying products. If a consumer builds a current-price display from the stream-filtered output, it can leave the old $119.00 price visible indefinitely.
Tombstones must survive through a pipeline that maintains a table. Dropping every null value before writing the filtered table's changes to its destination would remove the instructions needed to delete old rows.
This topology fragment uses Java 21 and Kafka Streams 4.3.1, with the org.apache.kafka:kafka-streams:4.3.1 dependency. It defines processing logic, not a standalone application entry point. To run it against Kafka, supply the returned topology to a KafkaStreams instance with an application ID and bootstrap servers, then start that instance.
The input topic is catalog.prices. Keys use Serdes.String(), and values use Serdes.Long(): a producer must write prices with a Kafka LongSerializer, not as decimal text from a default console producer. All prices use USD cents. The input contract permits tombstones but requires non-null product keys and non-negative prices.
Create catalog.prices, catalog.price-events-under-120, and catalog.current-prices-under-120 before starting the application. Use the cluster's appropriate replication and security settings. Both outputs keep the same string-key and long-value encoding as the input, and the current-price output also carries tombstones.
The first branch filters individual records. Its explicit null check prevents the predicate from comparing a tombstone value with a number.
The second branch calls toTable() to interpret the incoming records as row updates. Its table filter does not evaluate the predicate for tombstones; Kafka Streams handles the deletion semantics. Finally, toStream() exposes the table's changes as records the application can write to an output topic.
For the 11900 followed by 14900 example, the event output contains the qualifying 11900 record. The current-price output communicates that the product no longer belongs in the filtered view, using a tombstone if Kafka Streams emitted the earlier row. Caching can affect which intermediate updates appear, so the durable meaning of the second output is the resulting table state.
When the input already describes table updates and you need no stream branch, read it directly with builder.table(...). For example, this alternative declaration uses a separate builder:
The source topic still carries ordinary Kafka records. table(...) tells the application to apply them as changes to keyed rows.
A stream of row changes can maintain a table, and a table can publish its changes as a stream. Stream-table duality describes this relationship. The conversions are useful, but they do not make history and current state interchangeable.
toTable() uses the incoming key and replacement value. It does not count records, add inventory movements, or deduplicate business events by inspecting an event ID inside the value. Those behaviors require their own logic.
toStream() exposes table updates as they flow through the topology. Calling it does not scan the table and emit a one-time snapshot of all rows. Nor does it recreate every historical input that contributed to the table.
In particular, Kafka Streams can cache table updates and combine successive changes to the same key before forwarding them. A downstream consumer might see the later price without seeing every intermediate price. This preserves the current-state meaning but is unsuitable as a guarantee of a complete price-change audit trail.
An audit requirement needs an event history whose publishing, retention, and processing policies preserve the required events. Turning a table back into a stream cannot restore history that the application never forwarded or Kafka has already removed.
The table's state also lives in the processing application. Materialization means maintaining a computed table in a state store. Kafka Streams can use such stores for processing and querying; it does not create a SQL table on a broker. Kafka Streams partitions a regular materialized KTable across tasks, so one application instance may hold only part of it. Exposing a complete query service requires routing and application code beyond declaring the table.
Replay must provide enough information to reconstruct the intended rows. A compacted topic can retain the latest values by key and is often suitable for a table's source. Declaring a KTable does not automatically configure compaction on that source topic, and time-based deletion can remove values still needed for a rebuild.
Deletion records need retention planning too. An application rebuilding from a topic must observe the necessary tombstones before cleanup removes them, or use another valid snapshot and recovery procedure. Kafka Streams recovery depends on the topology, its state stores, and the retained source or changelog data; table semantics alone do not guarantee recoverability.
The examples use ordinary, unversioned tables and records that the application processes in partition order. Here, a later processed update for a key replaces the earlier value. “Latest” does not automatically mean the record with the greatest business timestamp.
Suppose an older $129.00 price update arrives late and Kafka appends it after the $119.00 update. An ordinary source table can replace $119.00 with $129.00. Kafka's partition order cannot determine that the application considers the incoming value stale. Versioned stores support different time-based semantics, but you must choose them explicitly; they are not the behavior assumed here.
Repeated records also have different effects depending on the computation. Applying the same replacement price twice leaves the same current row value, whereas counting two stream records can increase a count twice. Stable final state does not guarantee that downstream notifications or external writes execute only once.
Use the meaning of the input and the result you need to guide the choice:
For the store, a price-change analysis pipeline and a current-price view can coexist. Their contracts should make clear whether output records are independent events or updates to keyed rows. That decision tells downstream consumers whether to append a new fact, replace a stored value, or delete a row.
KStream treats records as independent inputs. KTable treats them as changes to keyed rows, with non-null values replacing rows and tombstones deleting them.
That difference affects the whole pipeline. Filtering a stream selects records; filtering a table maintains matching rows, including removals when an update no longer qualifies. Converting between the two views does not recover discarded history or replace the need for explicit aggregation.
Choose the abstraction from the record contract and the result you need. Keep keys, ordering, retention, and downstream update semantics consistent with that choice.