Rows — hash a whole row¶
plh.hash_rows is an expression that hashes a row and not one value.
A hash of the joined columns is not sufficient. The rows ("ab", "c") and
("a", "bc") make the same string, and therefore the same digest. One null makes the
full row null. A List, an Array or a Struct column has no string form.
hash_rows writes each row as bytes that no other row can make. Any hasher in this
package then reads those bytes.
All the examples on this page use this data:
import polars as pl
import polars_hash as plh
df = pl.DataFrame(
{"foo": ["hello_world"], "bar": [42], "baz": [[1, 2, 3]], "qux": [{"a": 1}]}
)
| Function | Input | Output |
|---|---|---|
hash_rows(exprs, version) |
Any columns | Binary |
hash_rows(exprs, *more_exprs, version)¶
Changes each row into Binary, for use with any hasher in this package.
You can keep, compare or store the bytes:
You can also give the column names. The names set the order of the row:
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
exprs |
IntoExpr \| Iterable[IntoExpr] |
required | The columns of the row, in order. This argument accepts all that pl.struct accepts, and also selectors. |
*more_exprs |
IntoExpr |
— | More columns, as positional arguments. |
version |
int |
1 |
The encoding to write. Version 1 does not change. |
Returns: Binary. There is one value for each row, and no value is null. A row with nulls also has a value.
The output column keeps the name of the first column, as pl.struct, pl.concat_str
and each *_horizontal expression do. Therefore with_columns replaces that column.
Use .alias() to keep it:
The encoding¶
This is version 1. These bytes do not change. A user can keep a hash for longer than
the release that made it. Therefore a new format takes a new version number, and
version=1 always gives these bytes.
A row is a struct value. It contains each column in order, and nothing more.
An example¶
Read the bytes from the left:
| Bytes | Meaning |
|---|---|
0d |
The row is a struct. |
02 |
The struct has two fields. |
03 |
The first field is an integer. |
01 |
The integer is zero or more. |
07 |
Its magnitude is 7. |
05 |
The second field is a string. |
02 |
The string has two bytes. |
6f 6b |
Those two bytes are ok. |
Each value starts with one tag byte, which gives the class of the value. The bytes after the tag are the payload. A payload has a fixed width, or it starts with its own length or count. Therefore the values need no separator, and a row has only one reading.
A null value is only its tag. A List and a Struct contain their values in the same form, one after the other. This row has a null and a list of 1 and 300:
pl.DataFrame({"id": pl.Series([None], dtype=pl.Int64), "tags": [[1, 300]]}).select(
plh.hash_rows(pl.all())
)
| Bytes | Meaning |
|---|---|
0d 02 |
A struct with two fields. |
00 |
The first field is null. |
0c |
The second field is a list. |
02 |
The list has two elements. |
03 01 01 |
The first element is the integer 1. |
03 01 ac 02 |
The second element is the integer 300. |
Numbers with a variable length¶
A count, a length, and the magnitude of an integer all use a varint. A varint takes one byte for a value below 128, and one more byte for each 7 bits after that.
Each byte of a varint holds 7 bits of the value, and the least significant 7 bits come first. Each byte has the high bit set, but the last byte does not:
| Value | Varint |
|---|---|
| 1 | 01 |
| 127 | 7f |
| 128 | 80 01 |
| 300 | ac 02 |
An integer payload is a sign byte and then a varint of the magnitude. The sign byte is
01 for zero and more, and 00 for less than zero. Therefore 300 is 01 ac 02, and
-300 is 00 ac 02.
The tags¶
| Tag | Class | Payload |
|---|---|---|
00 |
Null | none |
01 |
False | none |
02 |
True | none |
03 |
Integer | an integer payload |
04 |
Float | 8 bytes, f64 big-endian |
05 |
String | the length as a varint, then the UTF-8 bytes |
06 |
Binary | the length as a varint, then the bytes |
07 |
Date | the days from 1970-01-01, as an integer payload |
08 |
Time | the nanoseconds from midnight, as an integer payload |
09 |
Datetime | the nanoseconds from 1970-01-01, as an integer payload |
0a |
Duration | the nanoseconds, as an integer payload |
0b |
Decimal | the unscaled value as an integer payload, then the scale as a varint |
0c |
List | the element count as a varint, then the elements |
0d |
Struct | the field count as a varint, then the fields |
How the encoder reads a value¶
Polars can store one value in more than one way. The encoder reads the value, and not the storage:
| Rule | Effect |
|---|---|
| All the integer widths use one class | Int8(1), Int64(1) and UInt64(1) give the same bytes |
Float16 and Float32 change to Float64 |
all the float widths give the same bytes for 1.5 |
-0.0 changes to 0.0, and each NaN payload changes to one NaN |
two rows that polars reads as equal make one hash |
| Time values change to nanoseconds | Datetime("ms") and Datetime("ns") give the same bytes for one time |
| The encoder does not read a time zone | a zone changes the display of a time, but not the time |
| A decimal loses the zeros at the end | 1.50 and 1.500 give the same bytes |
Categorical and Enum change to their string |
the physical index depends on the order of the values, and therefore the encoder does not read it |
An Array changes to a List |
Array(Int64, 2) of [1, 2] and List of [1, 2] give the same bytes |
Which values stay different¶
| Rule | Effect |
|---|---|
| A null is a value | null, "" and 0 are three values, and a row with a null also has a hash |
A null List is not an empty List |
a null Struct is also not a struct of nulls |
| Each class has its own tag | 1 and 1.0 are different, and a Date and the Datetime at midnight are also different |
| The encoder reads the column order | (1, 2) and (2, 1) are different |
A Struct is not its fields |
the field count makes one Struct of two Int fields different from two Int columns |
What the encoder does not read¶
The column names. A new name for a column keeps all the hashes. A new order of the columns does not, because the order identifies the values. A column that you give two times is two values of the row.
The field names of a Struct. The encoder writes the fields in their order, as it
writes the columns of a row. Therefore a new name for a field keeps the hash, and a new
order of the fields does not.
Polars compares two Struct values by name, and the encoder reads the order. Therefore
the two do not always agree:
first = pl.Series([{"a": 2, "b": 1}], dtype=pl.Struct({"a": pl.Int64, "b": pl.Int64}))
second = pl.Series([{"b": 1, "a": 2}], dtype=pl.Struct({"b": pl.Int64, "a": pl.Int64}))
first.equals(second) # True: each name has the same value
# The hashes are different, because the values are in a different order.
The data type of an empty List. An empty List(Int64) and an empty
List(String) both give the count 0 and no elements.
The chunks and the slices. One chunk, ten chunks, or a slice of a larger frame all give the same bytes for the same rows.