Summary: different types when appending pandas dataframe to BigQuery causing issues with daily ETL process.
I am working on a straight-forward ETL with Airflow: pull data from an API daily, back that raw data up in JSON files in Google Cloud Storage (GCS), and then append the data from GCS into a BigQuery database. I am doing okay with the extract part of the ETL, calling the API and saving the results of each API call (which will be a row in the database table) as its own JSON object in GCS. For a table in BigQuery with 1K rows then, I will first create / save 1K separate objects saved into a bucket in GCS, each the result of an API call.
I am now struggling with the load part of ETL. So far, I have written the following script to do the transfer from GCS to BQ:
# load libraries, connect to google
from google.cloud import storage
import os
import gcsfs
import json
os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = '/path/to/my/credentials'
# transfer data
def load_directory_to_bq():
# get list of filenames from GCS directory
client = storage.Client()
files = []
blobs = client.list_blobs('my-gcs-bucket', prefix='gcs-path-to-files')
for blob in blobs:
files.append(f'my-gcs-bucket/{blob.name}')
# approach A: This loop pulls json, converts into df, writes to BigQuery, each 1 file at a time
fs = gcsfs.GCSFileSystem() # GCP's Google Cloud Storage (GCS) File System (FS)
for file in files:
with fs.open(file, 'r') as f:
gcs_data = json.loads(f.read())
data = [gcs_data] if isinstance(gcs_data, dict) else gcs_data
this_df = pd.DataFrame(data)
pd.DataFrame.to_gbq(this_df, 'my-bq-tablename', project_id='my-gcp-project-id', if_exists='append')
# approach B: This loop loops all the files, creates 1 large dataframe, and does 1 large insert into BigQuery
output_df = pd.DataFrame()
fs = gcsfs.GCSFileSystem() # GCP's Google Cloud Storage (GCS) File System (FS)
for file in files:
with fs.open(file, 'r') as f:
gcs_data = json.loads(f.read())
data = [gcs_data] if isinstance(gcs_data, dict) else gcs_data
this_df = pd.DataFrame(data)
output_df = output_df.append(this_df)
pd.DataFrame.to_gbq(output_df, 'my-bq-tablename', project_id='my-gcp-project-id', if_exists='append')
The 1K objects in GCS are all similar, but do not always have exactly the same structure:
- nearly all the same keys
- almost always the same "type" for each key
However, for some of the JSON objects, the "types" can be different, for the same key, across different objects. When loaded into python as a 1-row pandas dataframe, the same key key1 may be a float or an integer depending on the value. Also, sometimes a key is missing in an object, or its value/property is null, which can mess up the "type" and cause issues when using the to_gbq function.
With approach A above, the first time an object / pandas DF has a different type, the following error is thrown: Please verify that the structure and data types in the DataFrame match the schema of the destination table. Approach A seems inefficient as well because it calls to_gbq for each of the 1K rows, and each call takes 2-3 seconds.
With approach B, the different "types" issue is seemingly resolved, as pandas handles different "types" in its append function for appending 2 dataframes together. As a result, I get 1 dataframe, and can append it to BigQuery. However, I remain concerned that in the future, there may be new data that I need to append that will not match the type already in the existing-table. After all, I am not querying BigQuery for the old table, appending to new data, and then re-creating the table. I am simply appending new rows, and I am worried that a table with a different "type" for one of the keys will cause an error and break my pipeline then.
In theory, approach A is nice because an approach that can handle any individual row being appended to the table with to_gbq without errors is good. But it requires ensuring the same keys / types for every single row. With approach B, i don't think it's good that python auto-coalesces different types into 1 type for the table, as this can seemingly cause issues down the line with new data coming in.
I am considering what the best approach here would be. As both are Google products, going from GCS to BQ should be straightforward, yet imperfect data makes it slightly tougher. In particular, should I define an explicit table schema somewhere, for each different BQ table, and write a python function that ensures the right types / converts wrong types to right types? Should I recreate the table in BQ each time? Should I avoid Python all-together and transfer from GCS to BQ in another way?