03 · Databases, Schemas, Tables & Data Types¶
Snowflake's objects nest in a fixed hierarchy, and most of the rules you'll trip over at the start are about names, table kinds and types. Three of them catch nearly everyone: unquoted identifiers are stored in upper case, primary keys are not enforced, and there are three timestamp types that behave differently.
The hierarchy¶
Organization
└── Account
├── Users, Roles, Warehouses, Integrations, Network policies (account-level objects)
└── Database
└── Schema
├── Tables, Views, Materialized views, Dynamic tables
├── Stages, File formats, Pipes, Streams, Tasks
├── Sequences, Functions (UDFs), Procedures
└── Policies (masking, row access), Tags
A fully qualified name has three parts: database.schema.object. Warehouses, users and roles live at the account level
and have one-part names.
CREATE DATABASE IF NOT EXISTS learn_db COMMENT = 'Snowflake Mastery Path labs';
CREATE SCHEMA IF NOT EXISTS learn_db.raw;
CREATE SCHEMA IF NOT EXISTS learn_db.analytics WITH MANAGED ACCESS;
WITH MANAGED ACCESS makes the schema owner (not each object's owner) control grants inside it. That's a good default for
shared schemas (Level 3 · 01).
Identifiers: the upper-case rule¶
Unquoted identifiers are case-insensitive and stored in upper case. Quoted identifiers are case-sensitive and stored exactly as written:
CREATE TABLE learn_db.raw.Customers (id INT); -- stored as CUSTOMERS
SELECT * FROM learn_db.raw.customers; -- works: resolves to CUSTOMERS
CREATE TABLE learn_db.raw."orderItems" (id INT); -- stored as orderItems
SELECT * FROM learn_db.raw.orderItems; -- fails: looks for ORDERITEMS
SELECT * FROM learn_db.raw."orderItems"; -- works
This bites most often when tools create tables with quoted lower-case or mixed-case names (some loaders and DataFrame
writers do), and then every query needs quotes. Prefer unquoted snake_case names and let them become upper case. The
QUOTED_IDENTIFIERS_IGNORE_CASE parameter changes this behaviour account-wide, and it's best decided before any objects
exist.
Three kinds of table¶
CREATE TABLE learn_db.raw.orders_history (order_id NUMBER, amount NUMBER(12,2)); -- permanent
CREATE TRANSIENT TABLE learn_db.raw.orders_staging (order_id NUMBER, amount NUMBER(12,2));
CREATE TEMPORARY TABLE session_scratch (order_id NUMBER, amount NUMBER(12,2));
| Permanent | Transient | Temporary | |
|---|---|---|---|
| Lives until | dropped | dropped | the session ends |
| Visible to | anyone with privileges | anyone with privileges | only the creating session |
| Time Travel | up to 90 days (Enterprise+), 1 on Standard | 0 or 1 day | 0 or 1 day |
| Fail-safe (7 days) | yes | no | no |
Transient tables exist to save storage on data you can rebuild: staging tables, intermediate ETL results, scratch tables. A permanent table that you rewrite completely every hour keeps every old version in Time Travel and then in Fail-safe, and you pay storage for all of it (lesson 08). Transient databases and schemas make everything created in them transient by default.
Data types worth knowing¶
| Type | Notes |
|---|---|
NUMBER(p, s) |
Exact decimal. INT, INTEGER, BIGINT are all aliases for NUMBER(38,0). Use it for money. |
FLOAT / DOUBLE |
64-bit binary floating point. Fine for measurements, wrong for money. |
VARCHAR(n) |
Default length 16,777,216 characters, maximum 128 MB. The declared length has no storage or performance cost, so don't agonize over it. |
BINARY |
Default 8 MB, maximum 64 MB. |
BOOLEAN |
TRUE / FALSE / NULL. |
DATE, TIME |
As expected. |
TIMESTAMP_NTZ |
"No time zone": wall-clock time, no zone stored or applied. |
TIMESTAMP_LTZ |
Stored as UTC, displayed in the session's TIMEZONE. |
TIMESTAMP_TZ |
Stores the time and its UTC offset. |
VARIANT, OBJECT, ARRAY |
Semi-structured data (lesson 07). A single value can be up to 128 MB. |
GEOGRAPHY, GEOMETRY |
Spatial types. |
VECTOR(FLOAT, n) |
Fixed-length embedding vectors (Level 4 · 04). |
Plain TIMESTAMP is an alias whose meaning is set by the TIMESTAMP_TYPE_MAPPING parameter (TIMESTAMP_NTZ by
default). That means two accounts with different settings interpret the same DDL differently. Name the specific type
in DDL. The usual choice is TIMESTAMP_NTZ holding UTC for event times, or TIMESTAMP_TZ when the original
offset matters.
Why NUMBER for money: a demonstration¶
Snowflake's FLOAT is IEEE-754 binary floating point, like Python's float and most databases' DOUBLE. The rounding
it produces can be shown on any engine. Here's DuckDB 1.5.6 locally (not Snowflake), summing 0.1 a million times:
-- DuckDB 1.5.6 (local), not Snowflake
SELECT SUM(0.1::DOUBLE) AS sum_double, SUM(0.1::DECIMAL(38,2)) AS sum_decimal
FROM range(1000000);
┌────────────────────┬───────────────┐
│ sum_double │ sum_decimal │
├────────────────────┼───────────────┤
│ 100000.00000000087 │ 100000.00 │
└────────────────────┴───────────────┘
In Snowflake, the same principle applies to FLOAT versus NUMBER(38,2). Exact output digits may differ, because the
order of floating-point additions in a parallel engine isn't fixed. That's a second reason not to use floats for
anything that must reconcile.
Constraints: declared, not enforced¶
CREATE TABLE learn_db.raw.customers (
customer_id NUMBER(38,0) NOT NULL PRIMARY KEY,
email VARCHAR NOT NULL UNIQUE,
country VARCHAR(2),
created_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()::TIMESTAMP_NTZ
);
On standard Snowflake tables, only NOT NULL is enforced. PRIMARY KEY, UNIQUE and FOREIGN KEY are accepted and
recorded as metadata, and BI tools and the optimizer can read them, but inserting two rows with the same
customer_id succeeds. (Hybrid tables do enforce them. They're a separate, row-oriented table type not covered here.)
Two consequences:
- Deduplication is your job. Load pipelines must use
MERGEorQUALIFY ROW_NUMBER() = 1(lesson 06), not rely on the table to reject duplicates. - Declaring constraints is still worthwhile. It documents intent, and some tools use it for joins and lineage. Add
RELYonly if you guarantee the data really satisfies them, because it lets the optimizer trust them (for example, to eliminate unnecessary joins).
Sequences and identity columns¶
CREATE SEQUENCE learn_db.raw.order_seq START = 1 INCREMENT = 1;
CREATE TABLE learn_db.raw.orders (
order_id NUMBER(38,0) DEFAULT learn_db.raw.order_seq.NEXTVAL,
customer_id NUMBER(38,0) NOT NULL,
amount NUMBER(12,2) NOT NULL,
ordered_at TIMESTAMP_NTZ NOT NULL
);
CREATE TABLE learn_db.raw.events (
event_id NUMBER(38,0) IDENTITY(1,1),
payload VARIANT
);
Sequence values are unique and increasing, but not gap-free, and under concurrency they aren't necessarily in commit order. Never use them as a count, or as proof of ordering.
How It Actually Works¶
All of these objects are metadata in the cloud services layer. Creating a database or schema writes catalogue entries and touches no storage. A table is a catalogue entry plus a list of the micro-partition files that hold its data, and that list is what changes on every commit. The differences between permanent, transient and temporary tables are differences in how long retired micro-partitions are kept (Time Travel and Fail-safe) and in who can see the catalogue entry (temporary tables are scoped to a session).
Types matter for storage because data is stored per column, compressed with encodings chosen per micro-partition.
Values of an exact NUMBER type are stored as integers scaled by the declared scale, and Snowflake picks a compact
physical width for the values actually present, which is why NUMBER(38,0) costs no more than a "smaller" integer
type. Strings are stored at their actual length, which is why VARCHAR's declared maximum costs nothing. Constraints
other than NOT NULL would require checking every write against existing data across many micro-partitions, which
is expensive in a scan-oriented, append-optimized engine. That's why they're informational.
Common mistakes¶
- Quoted mixed-case identifiers from tools, forcing quotes forever. Standardize on unquoted names.
- Trusting
PRIMARY KEYto prevent duplicates. It doesn't. - Using plain
TIMESTAMPin DDL and gettingNTZin one account andLTZin another. Name the type. - Storing local wall-clock times without a zone in
TIMESTAMP_NTZ, and then being unable to compare across regions. Store UTC. - Permanent tables for disposable data. You pay Fail-safe storage on every rewrite. Use transient tables.
FLOATfor money. UseNUMBER(p, s).
Exercise¶
- Create
Customers,"Customers"and"customers"in one schema. How many tables exist? Write theSELECTthat reads each one. - Insert two rows with the same primary key into
learn_db.raw.customers, then write a query that finds all duplicated keys, and aQUALIFYquery that keeps only the newest row per key. - Set your session
TIMEZONEtoAmerica/New_York, insertCURRENT_TIMESTAMP()into columns of each timestamp type, change the session time zone toAsia/Kolkata, and select them again. Which values changed? - Create a transient schema and a table inside it, then check
SHOW TABLESfor itskindandretention_time.