This guide loads company data into Snowflake from the two ways Fokals delivers it, bulk files and the REST API, and keeps a history table in step with a daily run. It uses Hiring Activity (company_hiring_daily) from the hiring dataset as the example, and the same landing-then-merge pattern applies to the other tables in the data dictionary, with the key each table needs. Fokals is delivered direct, by REST API and as bulk files, which you load with Snowflake's own COPY INTO from a stage: you pull the data, stage it, load it and schedule the run.
The shape of the pipeline
Land the data first and model it second. A landing table takes the file as delivered, and a MERGE moves the rows you have not seen into the table you query. The split has two reasons. Snowflake's COPY INTO reference does not allow MATCH_BY_COLUMN_NAME together with a SELECT transformation, and loading by header name is what keeps a load stable when columns move. A landing table you can inspect also shows what arrived when a number looks wrong.
The tables below name only the columns the example uses. Add the others from the data dictionary: a column that is in the file and not in the table is ignored, and a column that is in the table and not in the file receives NULL, as Snowflake documents for the option. File paths and names in the SQL are illustrative and follow your own convention.
Stages and file formats
Snowflake loads from a stage, and there are two kinds to choose between.
| Stage | Where the files live | How files get there |
|---|---|---|
| Internal | In Snowflake | PUT from the Snowflake CLI, SnowSQL or a driver |
| External | Your own storage in Amazon S3, Google Cloud Storage or Azure | The storage provider's own tools, and a storage integration for access |
The PUT reference says the command cannot be run from a worksheet and does not work with external stages, and that it compresses a file with gzip unless you turn that off. The staging guide names the clients that can run it. For an external stage Snowflake recommends a storage integration over embedded credentials. An internal stage suits one job that downloads the files and loads them. An external stage suits an organisation that already keeps vendor files in a bucket it controls, copied there by its own job.
Snowflake's guidance on preparing files aims at 100 to 250 MB compressed, split by line so that no record spans two files. Its load considerations advise staging under logical paths by date. Follow both: give each run its own folder, named for the closed day or the period the files cover.
create database if not exists company_data;
create schema if not exists company_data.fokals;
use schema company_data.fokals;
-- an internal named stage for files you upload with PUT
create stage if not exists landing;
-- CSV with a header row: load by column name, JSON cells enclosed in quotes
create file format if not exists csv_header
type = csv
parse_header = true
field_optionally_enclosed_by = '"'
error_on_column_count_mismatch = false; -- a file may carry columns the table lacks
-- one JSON object per line
create file format if not exists jsonl
type = json;Bulk CSV files
The data dictionary says files are CSV in UTF-8 with one header row, and that lists and objects are JSON in a single cell. UTF-8 is Snowflake's default encoding, so the first fact needs no option. The header row lets PARSE_HEADER = TRUE and MATCH_BY_COLUMN_NAME load by name, so column order does not matter. The header names are lower case and Snowflake stores unquoted column names in upper case, so match case-insensitively.
A cell that holds JSON contains commas and quotes, which is why the format above encloses fields in double quotes. Test one file before you rely on that.
For a table you have not met yet, look at the file before you design anything. INFER_SCHEMA reads the header and a sample of rows and reports the names and types it would use, every column nullable, and CREATE TABLE ... USING TEMPLATE builds a first landing table from that output. Replace the inferred types where you know better.
select *
from table(infer_schema(
location => '@landing/hiring_daily/2026-10-03/',
file_format => 'csv_header'));Keep an empty cell as NULL. In the dictionary an empty cell means no value was recorded, and never a zero. Snowflake's EMPTY_FIELD_AS_NULL option is TRUE by default, so a plain load already gives NULL. Do not add a NULL_IF that turns blanks into text or numbers.
Land each JSON cell as text. Snowflake's page on transforming data during a load lists PARSE_JSON among the supported functions, but a transformation cannot be combined with matching by name, so parse in the MERGE with TRY_PARSE_JSON. It returns NULL for text that is not valid JSON, where PARSE_JSON would fail the statement, so count its NULL results against the empty cells and a parse failure cannot pass unseen. Dates, numbers and booleans load into typed columns under the default AUTO formats. The dictionary gives times in UTC, so declare any timestamp column as timestamp_tz.
create table if not exists hiring_daily_landing (
company_id varchar,
company varchar,
isin varchar,
day date,
open_postings number,
new_postings number,
closed_postings number,
by_function varchar, -- a JSON object, kept as text for now
median_salary_usd number(18,2),
reconstructed boolean
);
-- from the Snowflake CLI, SnowSQL or a driver, not from a worksheet
put file:///data/fokals/hiring_daily_2026-10-03.csv @landing/hiring_daily/2026-10-03/;
truncate table hiring_daily_landing;
copy into hiring_daily_landing
from @landing/hiring_daily/2026-10-03/
file_format = (format_name = 'csv_header')
match_by_column_name = case_insensitive;Leave on_error at its bulk default, abort_statement, so a bad file stops the run before the merge. To see every error in a file and not only the first, load once with on_error = continue and read the output of Snowflake's VALIDATE function, select * from table(validate(hiring_daily_landing, job_id => '_last')). Snowflake does not allow VALIDATION_MODE together with MATCH_BY_COLUMN_NAME, so that route is closed here.
JSON Lines from the API
Two sources give you JSON Lines. A bulk export can be requested in that format, and your own loader can write each record of an API page as one line. The feeds are cursor-paginated and run oldest first from a time you set, so the last cursor you received is your bookmark. Snowflake supports newline-delimited JSON and loads each top-level, complete object as a separate row, so a JSON Lines file needs no outer-array option. STRIP_OUTER_ARRAY is for a file that is one JSON array.
create table if not exists signals_raw (src variant);
truncate table signals_raw;
copy into signals_raw
from @landing/feed/2026-10-04/
file_format = (format_name = 'jsonl');
-- check what arrived before you rely on a path: expect OBJECT
select typeof(src:by_function) as value_type, count(*) as records
from signals_raw
group by 1;
select src:company_id::varchar as company_id,
src:day::date as day,
src:open_postings::number as open_postings,
src:by_function.sales::number as open_sales_postings
from signals_raw;In a VARIANT, element names are case-sensitive and the column name is not, so write each path exactly as the key is spelt. Snowflake also stores dates and timestamps from JSON as strings, so cast them when you model the table, as the select above does.
Daily increments with MERGE
Daily and weekly rows are written once, after the period closes, and are never changed, and reconstructed marks a period written more than seven days late. For those tables an insert-only MERGE fits: a row is added when its key is new and left alone otherwise, so repeating a run cannot alter history. The key of company_hiring_daily is company_id with day. A weekly table uses week_start in place of day, plus any other grain the dictionary names, such as the topic of an intent score.
The source needs care. Snowflake's MERGE reference says that when the source holds duplicate rows with no match in the target, the target receives one copy of each. Remove duplicates from the source first, as the select distinct below does.
create table if not exists company_hiring_daily (
company_id varchar,
company varchar,
isin varchar,
day date,
open_postings number,
new_postings number,
closed_postings number,
by_function variant,
median_salary_usd number(18,2),
reconstructed boolean
);
merge into company_hiring_daily as t
using (
select company_id, company, isin, day, open_postings, new_postings, closed_postings,
try_parse_json(by_function) as by_function, median_salary_usd, reconstructed
from (
select distinct company_id, company, isin, day, open_postings, new_postings,
closed_postings, by_function, median_salary_usd, reconstructed
from hiring_daily_landing
)
) as s
on t.company_id = s.company_id and t.day = s.day
when not matched then insert
(company_id, company, isin, day, open_postings, new_postings, closed_postings,
by_function, median_salary_usd, reconstructed)
values
(s.company_id, s.company, s.isin, s.day, s.open_postings, s.new_postings,
s.closed_postings, s.by_function, s.median_salary_usd, s.reconstructed);Snowflake also remembers which files a table has loaded and skips them, but its load metadata expires after 64 days, and truncating a table deletes it, which Snowflake documents as allowing the same files to load again. That is why the landing table is truncated at the start of a run: a failed run can be repeated from the top. Treat file bookkeeping as a convenience and the merge key as the guard. Keep reconstructed in the history table so late periods can be separated when timing matters.
The daily run
The loader runs outside Snowflake, on whatever scheduler you already use, and each run follows the same order.
- Read the bookmark from a state table,
create table if not exists load_state (feed varchar, last_cursor varchar). On the first run use the time you want to start from. A first run is a backfill of the period you request. - Pull. For files, download the export covering the period since the last run. For the API, request the feed from the bookmark page by page, write each record as one line, and stay inside the per-minute and per-day limits of your key.
PUTthe files into the run's stage path.- Truncate the landing table and
COPY INTOit from that path. MERGEinto the history table.- Save the new bookmark only after the merge succeeded.
Steps 4 and 5 can run inside Snowflake. A task runs SQL or a stored procedure on a cron schedule and must be resumed after it is created. Snowflake's external network access also lets a function or procedure call an outside endpoint with a stored secret, if you prefer to keep the API call in Snowflake. This guide keeps it in the loader.
Checks and scheduling
Check three things after each run. copy_history shows each file with its status and row_count, though the table function returns up to 14 days. The latest day in the history table should be the day you expected. The period and label versions in the export manifest should match what you loaded.
Snowflake describes Snowpipe as a service for loading micro-batches, and a daily file is a batch that a scheduled COPY INTO loads. Delivery is direct, so your loader decides when a file exists, and the delivery page describes the pull.
For the incremental side, see keeping a warehouse in step with incremental API sync. Once the tables are in, joining them to CRM tables is the next step.
Frequently asked questions
How do I load a CSV file with a header row into Snowflake?
Stage the file with PUT or in your cloud storage, create a file format with TYPE = CSV and PARSE_HEADER = TRUE, then run COPY INTO with MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE. Columns load by name in any order. SKIP_HEADER cannot be combined with PARSE_HEADER, and a COPY transformation cannot be combined with matching by name.
How do I load JSON Lines into Snowflake?
Create a file format with TYPE = JSON and run COPY INTO a table with one VARIANT column. Snowflake supports newline-delimited JSON and loads each top-level object as a row, so no outer-array option is needed. Read fields with colon paths such as src:company_id::varchar, and use STRIP_OUTER_ARRAY only for a file that is one JSON array.
How do I load a JSON column from a CSV file into a VARIANT column?
Load the cell into a text column, then convert it with TRY_PARSE_JSON in the statement that moves rows into the table you query. TRY_PARSE_JSON returns NULL when the text is not valid JSON, so one bad cell does not stop the load, and you should count the NULLs against the empty cells. The result is a VARIANT you can read with colon paths.
How do I stop COPY INTO loading the same file twice?
Snowflake keeps load metadata for each table and skips files it has already loaded, but only for 64 days, and truncating the table deletes it. Stage each run under its own dated path, and make the history table safe on its own with a MERGE on the business key, here company ID and day, so a repeated file inserts nothing new.
Should I use Snowpipe or COPY INTO for a daily file?
For one file or a few per day, a scheduled COPY INTO is enough. Snowflake describes Snowpipe as designed for micro-batches loaded incrementally. Fokals is delivered direct, so your loader decides when a file exists and can run COPY INTO as soon as PUT finishes, as described on the delivery page.
The queries and code on this page are examples to adapt. Test them in your own environment before you rely on them.
What this page says about the products it names was checked against their public documentation on 4 October 2026. Product and company names are trademarks of their owners. Fokals is not affiliated with them or endorsed by them.