Repository: agrc/palletjack (language: python)

Issue:
Actually implement geocoder API Check
Written but not called.
Pre-check and/or fill nans before append
`FeatureLayer.append` apparently really doesn't like `nans`/`Nones` in it's source dataframe (or maybe it's the conversion to geojson via feature set). It will throw a weird `Exception: Object reference not set to an instance of an object. (Error Code: 400)` error. Removing any nans (filling to 0s or empty strings) seems to fix this. 

Palletjack should at the very least check for nans or fill them with appropriate data (0s -999, or something).

Or, investigate further to see if it's something in the geojson conversion that can more gracefully handle nans.

Is it something to do with certain fields that aren't set to nullable in AGOL?
upsert existing data: missing keys in new data can cause switch from ints to floats to handle nans 
If we have an int column in the live data and the new data has a row that isn't present in the live data, the `.merge` in `_get_common_rows()` will cause the int column to be a float to handle nans.

Maybe look at the updated pandas int type that can handle nans? Maybe force all original int fields back to ints?
Check for projection match, error out if not
Currently naively assumes same projection. Need to reproject data. Also, may need to check transformation to ensure no large shifts, etc.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 0b864a9..9dc2c69 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
 
+#: testing boilerplate
+boilerplate.py
+
 #: Secrets file
 secrets.py
 known_hosts
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 767efcf..0d40a8c 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,22 +1,44 @@
 {
   "cSpell.words": [
     "AGRC",
+    "authed",
+    "auths",
+    "ceildiv",
     "Codecov",
+    "concatted",
+    "Concatting",
+    "Dataframe",
+    "DataFrame",
+    "Dtype",
     "featurelayer",
     "featureset",
     "fsupdater",
+    "geocheck",
+    "Geocoded",
+    "geodataframe",
+    "geojsons",
+    "geopandas",
     "gsheet",
     "instafail",
     "isort",
     "knownhosts",
+    "minval",
     "Overwriter",
     "palletjack",
     "PARENTOBJECTID",
+    "Polyline",
+    "psycopg",
     "pylint",
     "pytest",
     "reclassifier",
+    "sedf",
     "sftploader",
+    "subsetted",
+    "subsetting",
+    "trycount",
     "unclassed",
+    "upserting",
+    "upserts",
     "yapf"
   ],
   "coverage-gutters.highlightdark": "rgb(61, 153, 112, .05)",
diff --git a/README.md b/README.md
index a66e989..b39bd75 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,17 @@ Pallet jack: [forklift's](https://www.github.com/agrc/forklift) little brother.
 1. Install in dev mode
    - `pip install -e .[tests]`
 
+### Troubleshooting Weird Append Errors
+
+If a `FeatureLayer.append()` call (within a load.FeatureServiceUpdater method) fails with an "Unknown Error: 500" error or something like that, you can query the results to get more info. The debug log will include the HTTP GET call, something like the following:
+`https://services1.arcgis.com:443 POST /<unique string>/arcgis/rest/services/<feature layer name>/FeatureServer/<layer id>/append/jobs/<job guid>?f=json token=<crazy long token string>`
+
+You can use this and a token from an AGOL tab to build a new job status url. To get the token, log into AGOL in a browser and open a private hosted feature layer item. Click the layer, and then open the developer console. With the Network tab of the console open, click on the "View" link for the service URL. You should see a document in the list whose name includes "?token=<really long token string>". Copy the name and then copy out the token string.
+
+Now that you've got the token string, you can build the status query:
+`https://services1.arcgis.com/<unique string>/arcgis/rest/services/<feature layer name>/FeatureServer/<layer id>/append/jobs/<job guid>?f=json&<token from agol>`
+
+Calling this URL in a browser should return a message that will hopefully give you more info as to why it failed.
 ### Updating pypi
 
 1. Delete everything in dist/
diff --git a/setup.py b/setup.py
index 97ccb55..26c3eb3 100644
--- a/setup.py
+++ b/setup.py
@@ -11,7 +11,7 @@
 
 setup(
     name='ugrc-palletjack',
-    version='2.6.2',
+    version='3.0.0',
     license='MIT',
     description='Updating AGOL feature services with data from SFTP shares.',
     author='Jake Adams, UGRC',
@@ -36,6 +36,10 @@
         'pysftp==0.2.9',
         'arcgis==2.0.*',
         'pygsheets==2.0.*',
+        'geopandas==0.12.*',
+        'SQLAlchemy==1.4.*',
+        'psycopg2-binary==2.9.*',
+        'numpy==1.23.*',  #: Pinned to fix "module 'numpy' has no attribute 'str'" error
     ],
     extras_require={
         'tests': [
diff --git a/src/palletjack/__init__.py b/src/palletjack/__init__.py
index 0c7852f..a6b333d 100644
--- a/src/palletjack/__init__.py
+++ b/src/palletjack/__init__.py
@@ -1,8 +1,4 @@
-"""A library for updating AGOL feature services with data from SFTP sources
+"""A library for updating AGOL feature services automatically with data from external sources.
 """
 
-from . import transform
-from .loaders import GoogleDriveDownloader, GSheetLoader, SFTPLoader
-from .updaters import (
-    ColorRampReclassifier, FeatureServiceAttachmentsUpdater, FeatureServiceInlineUpdater, FeatureServiceOverwriter
-)
+from . import extract, load, transform, utils
diff --git a/src/palletjack/loaders.py b/src/palletjack/extract.py
similarity index 90%
rename from src/palletjack/loaders.py
rename to src/palletjack/extract.py
index ccecc78..af733e6 100644
--- a/src/palletjack/loaders.py
+++ b/src/palletjack/extract.py
@@ -8,10 +8,12 @@
 from pathlib import Path
 from time import sleep
 
+import geopandas as gpd
 import pandas as pd
 import pysftp
 import requests
 from googleapiclient.http import MediaIoBaseDownload
+from sqlalchemy import create_engine
 
 from . import utils
 
@@ -84,7 +86,7 @@ def combine_worksheets_into_single_dataframe(self, worksheet_dfs):
 
         #: Make sure all the dataframes have the same columns
         if not all([set(dataframes[0].columns) == set(df.columns) for df in dataframes]):
-            raise ValueError('Columns do not match; cannot create mutli-index dataframe')
+            raise ValueError('Columns do not match; cannot create multi-index dataframe')
 
         self._class_logger.debug('Concatting worksheet dataframes %s into a single dataframe', worksheet_dfs.keys())
         concatted_df = pd.concat(dataframes, keys=worksheet_dfs.keys(), names=['worksheet', 'row'])
@@ -226,7 +228,7 @@ def _get_request_and_filename_from_drive_api(self, client, file_id):
         """Get the request object and filename from a Google drive file_id. Will try to determine extension if missing.
 
         Args:
-            client (pygsheets.Client): Authenticated client object from pyghseets
+            client (pygsheets.Client): Authenticated client object from pygsheets
             file_id (str): The Google fileId to be downloaded
 
         Returns:
@@ -429,3 +431,41 @@ def read_csv_into_dataframe(self, filename, column_types=None):
         if len(dataframe.index) == 0:
             self._class_logger.warning('Dataframe contains no rows. Shape: %s', dataframe.shape)
         return dataframe
+
+
+class PostgresLoader:
+    """Loads data from a Postgres/PostGIS database into a pandas data frame"""
+
+    def __init__(self, host, database, username, password, port=5432):
+        self._class_logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
+
+        connection_string = f'postgresql://{username}:{password}@{host}:{port}/{database}'
+        self.engine = create_engine(connection_string)
+
+    def read_table_into_dataframe(self, table_name, index_column, crs, spatial_column):
+        """Read a table into a dataframe
+        Args:
+            table_name (str): Name of table or view to read in the following format: schema.table_name
+            index_column (str): Name of column to use as the dataframe's index
+            crs (str): Coordinate reference system of the table's geometry column
+            spatial_column (str): Name of the table's geometry or geography column
+        Returns:
+            pd.DataFrame.spatial: Table as a spatially enabled dataframe
+        """
+
+        self._class_logger.info('Reading `%s` into dataframe', table_name)
+
+        dataframe = gpd.read_postgis(
+            f'select * from {table_name}', self.engine, index_col=index_column, crs=crs, geom_col=spatial_column
+        )
+
+        spatial_dataframe = pd.DataFrame.spatial.from_geodataframe(dataframe, column_name=spatial_column)
+        for column in spatial_dataframe.select_dtypes(include=['datetime64[ns, UTC]']):
+            self._class_logger.debug('Converting column `%s` to ISO string format', column)
+            spatial_dataframe[column] = spatial_dataframe[column].apply(pd.Timestamp.isoformat)
+
+        self._class_logger.debug('Dataframe shape: %s', spatial_dataframe.shape)
+        if len(spatial_dataframe.index) == 0:
+            self._class_logger.warning('Dataframe contains no rows. Shape: %s', spatial_dataframe.shape)
+
+        return spatial_dataframe
diff --git a/src/palletjack/load.py b/src/palletjack/load.py
new file mode 100644
index 0000000..5758c08
--- /dev/null
+++ b/src/palletjack/load.py
@@ -0,0 +1,954 @@
+"""Classes for loading data from spatially-enabled pandas DataFrames into AGOL Feature Services
+"""
+
+import json
+import logging
+import sys
+import warnings
+from datetime import datetime
+from pathlib import Path
+
+import arcgis
+import numpy as np
+import pandas as pd
+from arcgis.features import GeoAccessor, GeoSeriesAccessor
+
+from palletjack import utils
+
+logger = logging.getLogger(__name__)
+
+
+class FeatureServiceUpdater:
+    """Update an AGOL Feature Service with data from a pandas DataFrame.
+
+    Contains four class methods that should be called directly without need to instantiate an instance: add_features,
+    remove_features, update_features, and truncate_and_load_features.
+    """
+
+    @classmethod
+    def add_features(cls, gis, feature_service_itemid, dataframe, layer_index=0):
+        """Adds new features to existing hosted feature layer from new dataframe.
+
+        The new dataframe must have a 'SHAPE' column containing geometries of the same type as the live data. New
+        OBJECTIDs will be automatically generated.
+
+        The new dataframe's columns and data must match the existing data's fields (with the exception of generated
+        fields like shape area and length) in name, type, and allowable length. Live fields that are not nullable and
+        don't have a default value must have a value in the new data; missing data in these fields will raise an error.
+
+        Args:
+            gis (arcgis.gis.GIS): GIS item for AGOL org
+            features_service_item_id (str): itemid for service to update
+            dataframe (pd.DataFrame.spatial): Spatially enabled dataframe of data to be added
+            layer_index (int): Index of layer within service to update. Defaults to 0.
+
+        Raises:
+            ValueError: If the new field and existing fields don't match, the SHAPE field is missing or has an
+                        incompatible type, the new data contains null fields, the new data exceeds the existing field
+                        lengths, or a specified field is missing from either new or live data.
+
+        Returns:
+            int: Number of features added
+        """
+        updater = cls(gis, feature_service_itemid, dataframe, fields=list(dataframe.columns), layer_index=layer_index)
+        return updater._add_new_data_to_hosted_feature_layer()
+
+    @classmethod
+    def remove_features(cls, gis, feature_service_itemid, delete_oids, layer_index=0):
+        """Deletes features from a hosted feature layer based on comma-separated string of Object IDs
+
+        This is a wrapper around the arcgis.FeatureLayer.delete_features method that adds some sanity checking. The
+        delete operation is rolled-back if any of the features fail to delete using (rollback_on_failure=True). This
+        function will raise a RuntimeError as well after delete_features() returns if any of them fail.
+
+        The sanity checks will raise errors or warnings as appropriate if any of them fail.
+
+        Args:
+            delete_oids (list[int]): List of OIDs to delete
+
+        Raises:
+            ValueError: If delete_string can't be split on `,`
+            TypeError: If any of the items in delete_string can't be cast to ints
+            ValueError: If delete_string is empty
+            UserWarning: If any of the Object IDs in delete_string don't exist in the live data
+            RuntimeError: If any of the OIDs fail to delete
+
+        Returns:
+            int: The number of features deleted
+        """
+
+        updater = cls(gis, feature_service_itemid, layer_index=layer_index)
+        return updater._delete_data_from_hosted_feature_layer(delete_oids)
+
+    @classmethod
+    def update_features(cls, gis, feature_service_itemid, dataframe, layer_index=0, update_geometry=True):
+        """Updates existing features within a hosted feature layer using OBJECTID as the join field.
+
+        The new data can have either attributes and geometries or only attributes based on the update_geometry flag. A
+        combination of updates from a source with both attributes & geometries and a source with attributes-only must
+        be done with two separate calls. The geometries must be provided in a SHAPE column and be the same type as the
+        live data.
+
+        The new dataframe's columns and data must match the existing data's fields (with the exception of generated
+        fields like shape area and length) in name, type, and allowable length. Live fields that are not nullable and
+        don't have a default value must have a value in the new data; missing data in these fields will raise an error.
+
+        Args:
+            gis (arcgis.gis.GIS): GIS item for AGOL org
+            features_service_item_id (str): itemid for service to update
+            dataframe (pd.DataFrame.spatial): Spatially enabled dataframe of data to be updated
+            layer_index (int): Index of layer within service to update. Defaults to 0.
+            update_geometry (bool): Whether to update attributes and geometry (True) or just attributes (False).
+            Defaults to False.
+
+        Raises:
+            ValueError: If the new field and existing fields don't match, the SHAPE field is missing or has an
+                        incompatible type, the new data contains null fields, the new data exceeds the existing field
+                        lengths, or a specified field is missing from either new or live data.
+
+        Returns:
+            int: Number of features updated
+        """
+
+        updater = cls(
+            gis,
+            feature_service_itemid,
+            dataframe,
+            fields=list(dataframe.columns),
+            layer_index=layer_index,
+        )
+        return updater._update_hosted_feature_layer(update_geometry)
+
+    @classmethod
+    def truncate_and_load_features(cls, gis, feature_service_itemid, dataframe, failsafe_dir='', layer_index=0):
+        """Overwrite a hosted feature layer by truncating and loading the new data
+
+        When the existing dataset is truncated, a copy is kept in memory as a spatially-enabled dataframe. If the new
+        data fail to load, this copy is reloaded. If the reload fails, the copy is written to failsafe_dir with the
+        filename {todays_date}.json (2022-12-31.json).
+
+        The new dataframe must have a 'SHAPE' column containing geometries of the same type as the live data. New
+        OBJECTIDs will be automatically generated.
+
+        The new dataframe's columns and data must match the existing data's fields (with the exception of generated
+        fields like shape area and length) in name, type, and allowable length. Live fields that are not nullable and
+        don't have a default value must have a value in the new data; missing data in these fields will raise an error.
+
+        Args:
+            gis (arcgis.gis.GIS): GIS item for AGOL org
+            feature_service_itemid (str): itemid for service to update
+            dataframe (pd.DataFrame.spatial): Spatially enabled dataframe of new data to be loaded
+            failsafe_dir (str, optional): Directory to save original data in case of complete failure. If left blank, existing data won't be saved. Defaults to ''
+            layer_index (int, optional): Index of layer within service to update. Defaults to 0.
+
+        Returns:
+            int: Number of features loaded
+        """
+
+        updater = cls(
+            gis,
+            feature_service_itemid,
+            dataframe,
+            fields=list(dataframe.columns),
+            failsafe_dir=failsafe_dir,
+            layer_index=layer_index
+        )
+        return updater._truncate_and_load_data()
+
+    def __init__(
+        self,
+        gis,
+        feature_service_itemid,
+        dataframe=None,
+        fields=None,
+        join_column=None,
+        failsafe_dir=None,
+        layer_index=0
+    ):
+        self._class_logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
+        self.feature_service_itemid = feature_service_itemid
+        self.feature_layer = arcgis.features.FeatureLayer.fromitem(gis.content.get(feature_service_itemid))
+        if dataframe is not None:
+            self.new_dataframe = dataframe.rename(columns=utils.rename_columns_for_agol(dataframe.columns))
+            if 'SHAPE' in self.new_dataframe.columns:
+                self.new_dataframe.spatial.set_geometry('SHAPE')
+        if fields:
+            renamed_fields = set(utils.rename_columns_for_agol(fields).values())
+            self.fields = list(renamed_fields - {'Shape_Area', 'Shape_Length'})  #: We don't use these auto-gen fields
+        if join_column:
+            self.join_column = utils.rename_columns_for_agol([join_column])[join_column]
+        if failsafe_dir:
+            self.failsafe_dir = failsafe_dir
+        self.layer_index = layer_index
+
+    #: NOTE: Saving all this for potential reuse in transform.py
+
+    # def __init__(self, gis, dataframe, index_column, field_mapping=None):
+    #     self._class_logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
+    #     self.gis = gis
+    #     self.index_column = utils.rename_columns_for_agol([index_column])[index_column]
+
+    #     if field_mapping:
+    #         dataframe = utils.rename_fields(dataframe, field_mapping)
+
+    #     self.new_dataframe = dataframe.rename(columns=utils.rename_columns_for_agol(dataframe.columns))
+    #     try:
+    #         self.new_data_as_dict = self.new_dataframe.set_index(self.index_column).to_dict('index')
+    #     except KeyError as error:
+    #         raise KeyError(f'Index column {index_column} not found in dataframe columns') from error
+
+    # def _get_common_rows(self, live_dataframe) -> pd.DataFrame:
+    #     """Create a dataframe containing only the rows common to both the existing live dataset and the new updates
+
+    #     Args:
+    #         live_dataframe (pd.Dataframe): The existing, live dataframe created from an AGOL hosted feature layer.
+
+    #     Returns:
+    #         pd.DataFrame: A new dataframe containing the common rows created by .merge()ing.
+    #     """
+
+    #     subset_dataframe = live_dataframe[live_dataframe[self.index_column] \
+    #         .isin(self.new_dataframe[self.index_column])] \
+    #         .set_index(self.index_column)
+    #     subset_dataframe.update(self.new_dataframe.set_index(self.index_column))
+
+    #     not_found_dataframe = self.new_dataframe[~self.new_dataframe[self.index_column]
+    #                                              .isin(live_dataframe[self.index_column])]
+    #     if not not_found_dataframe.empty:
+    #         keys_not_found = list(not_found_dataframe[self.index_column])
+    #         self._class_logger.warning(
+    #             'The following keys from the new data were not found in the existing dataset: %s', keys_not_found
+    #         )
+
+    #     return subset_dataframe.reset_index()
+
+    # def _clean_dataframe_columns(self, dataframe, fields) -> pd.DataFrame:
+    #     """Delete superfluous fields from dataframe that will be used for the .edit() operation
+
+    #     Removes or renames the '_x', '_y' fields that are created by dataframe.merge() as appropriate based on the
+    #     fields provided.
+
+    #     Args:
+    #         dataframe (pd.Dataframe): Dataframe to be cleaned
+    #         fields (list[str]): The fields to keep; all others will be deleted
+
+    #     Returns:
+    #         pd.DataFrame: A dataframe with only our desired columns.
+    #     """
+
+    #     #: rename specified fields, delete any other _x/_y fields (in effect, just keep the specified fields...)
+    #     #: TODO: There may be a simpler way to do this- maybe .reindex(fields)?
+    #     rename_dict = {f'{f}_y': f for f in fields}
+    #     renamed_dataframe = dataframe.rename(columns=rename_dict).copy()
+    #     fields_to_delete = [f for f in renamed_dataframe.columns if f.endswith(('_x', '_y'))]
+    #     fields_to_delete.append('_merge')
+    #     self._class_logger.debug('Deleting joined dataframe fields: %s', fields_to_delete)
+    #     return renamed_dataframe.drop(labels=fields_to_delete, axis='columns', errors='ignore').copy()
+
+    # def _get_old_and_new_values(self, live_dict, object_ids):
+    #     """Create a dictionary of the old (existing, live) and new values based on a list of object ids
+
+    #     Args:
+    #         live_dict (dict): The old (existing, live) data, key is an arbitrary index, content is another dict keyed
+    #         by the field names.
+    #         object_ids (list[int]): The object ids to include in the new dictionary
+
+    #     Returns:
+    #         dict: {object_id:
+    #                 {'old_values': {data as dict from live_dict},
+    #                  'new_values': {data as dict from self.new_dataframe}
+    #                 },
+    #               ...,
+    #               }
+    #     """
+    #     oid_and_key_lookup = {
+    #         row['OBJECTID']: row[self.index_column] for _, row in live_dict.items() if row['OBJECTID'] in object_ids
+    #     }
+    #     old_values_by_oid = {row['OBJECTID']: row for _, row in live_dict.items() if row['OBJECTID'] in object_ids}
+    #     new_data_as_dict_preserve_key = self.new_dataframe.set_index(self.index_column, drop=False).to_dict('index')
+    #     new_values_by_oid = {
+    #         object_id: new_data_as_dict_preserve_key[key] for object_id, key in oid_and_key_lookup.items()
+    #     }
+
+    #     combined_values_by_oid = {
+    #         object_id: {
+    #             'old_values': old_values_by_oid[object_id],
+    #             'new_values': new_values_by_oid[object_id]
+    #         } for object_id in oid_and_key_lookup
+    #     }
+
+    #     return combined_values_by_oid
+
+    # def _parse_results(self, results_dict, live_dataframe) -> int:
+    #     """Integrate .edit() results with data for reporting purposes. Relies on _get_old_and_new_values().
+
+    #     Args:
+    #         results_dict (dict): AGOL response as a python dict (raw output from .edit_features(). Defined in
+    #         https://developers.arcgis.com/rest/services-reference/enterprise/apply-edits-feature-service-layer-.htm,
+    #         where `true`/`false` are python True/False.
+    #         live_dataframe (pd.DataFrame): Existing/live dataframe created from hosted feature layer.
+
+    #     Returns:
+    #         int: Number of edits successfully applied. If any failed, rollback will cause this to be set to 0.
+    #     """
+
+    #     live_dataframe_as_dict = live_dataframe.to_dict('index')
+    #     update_results = results_dict['updateResults']
+    #     if not update_results:
+    #         self._class_logger.info('No update results returned; no updates attempted')
+    #         return 0
+    #     update_successes = [result['objectId'] for result in update_results if result['success']]
+    #     update_failures = [result['objectId'] for result in update_results if not result['success']]
+    #     rows_updated = len(update_successes)
+    #     if update_successes:
+    #         self._class_logger.info('%s rows successfully updated:', len(update_successes))
+    #         for _, data in self._get_old_and_new_values(live_dataframe_as_dict, update_successes).items():
+    #             self._class_logger.debug('Existing data: %s', data['old_values'])
+    #             self._class_logger.debug('New data: %s', data['new_values'])
+    #     if update_failures:
+    #         self._class_logger.warning(
+    #             'The following %s updates failed. As a result, all successful updates should have been rolled back.',
+    #             len(update_failures)
+    #         )
+    #         for _, data in self._get_old_and_new_values(live_dataframe_as_dict, update_failures).items():
+    #             self._class_logger.warning('Existing data: %s', data['old_values'])
+    #             self._class_logger.warning('New data: %s', data['new_values'])
+    #         rows_updated = 0
+
+    #     return rows_updated
+
+    def _add_new_data_to_hosted_feature_layer(self) -> int:
+        """Adds new features to existing hosted feature layer. Uses fields from new dataframe.
+
+        Raises:
+            ValueError: If the new field and existing fields don't match, the new data contains null fields,
+                        the new data exceeds the existing field lengths, or a specified field is missing from either
+                        new or live data.
+
+        Returns:
+            int: Number of features added
+        """
+
+        self._class_logger.info(
+            'Adding items to layer `%s` in itemid `%s` in-place', self.layer_index, self.feature_service_itemid
+        )
+        self._class_logger.debug('Using fields %s', self.fields)
+
+        #: Field checks to prevent Error: 400 errors from AGOL
+        field_checker = utils.FieldChecker(self.feature_layer.properties, self.new_dataframe)
+        field_checker.check_live_and_new_field_types_match(self.fields)
+        field_checker.check_for_non_null_fields(self.fields)
+        field_checker.check_field_length(self.fields)
+        field_checker.check_fields_present(self.fields, add_oid=False)
+        field_checker.check_srs_match()
+        #: FIXME: remove once Esri fixes https://github.com/Esri/arcgis-python-api/issues/1281
+        field_checker.check_for_empty_float_fields(self.fields)
+
+        #: Upsert
+        append_count = self._upsert_data(
+            self.feature_layer,
+            self.new_dataframe,
+            upsert=False,
+        )
+        return append_count
+
+    def _delete_data_from_hosted_feature_layer(self, delete_oids) -> int:
+        """Deletes features from a hosted feature layer based on comma-separated string of OIDS
+
+        Args:
+            delete_oids (list[int]): List of OIDs to delete
+
+        Raises:
+            RuntimeError: If any of the OIDs fail to delete
+
+        Returns:
+            int: The number of features deleted
+        """
+
+        self._class_logger.info(
+            'Deleting features from layer `%s` in itemid `%s`', self.layer_index, self.feature_service_itemid
+        )
+        self._class_logger.debug('Delete string: %s', delete_oids)
+
+        #: Verify delete list
+        # oid_list = utils.DeleteUtils.check_delete_oids_are_comma_separated(delete_oids)
+        oid_numeric = utils.DeleteUtils.check_delete_oids_are_ints(delete_oids)
+        utils.DeleteUtils.check_for_empty_oid_list(oid_numeric, delete_oids)
+        delete_string = ','.join([str(oid) for oid in oid_numeric])
+        num_missing_oids = utils.DeleteUtils.check_delete_oids_are_in_live_data(
+            delete_string, oid_numeric, self.feature_layer
+        )
+
+        #: Note: apparently not all services support rollback: https://developers.arcgis.com/rest/services-reference/enterprise/delete-features.htm
+        deletes = utils.retry(
+            self.feature_layer.delete_features,
+            deletes=delete_oids,
+            rollback_on_failure=True,
+        )
+
+        failed_deletes = [result['objectId'] for result in deletes['deleteResults'] if not result['success']]
+        if failed_deletes:
+            raise RuntimeError(f'The following Object IDs failed to delete: {failed_deletes}')
+
+        #: The REST API still returns success: True on missing OIDs, so we have to track this ourselves
+        actual_delete_count = len(deletes['deleteResults']) - num_missing_oids
+
+        return actual_delete_count
+
+    def _update_hosted_feature_layer(self, update_geometry) -> int:
+        """Updates existing features within a hosted feature layer using OBJECTID as the join field
+
+        Raises:
+            ValueError: If the new field and existing fields don't match, the new data contains null fields,
+                        the new data exceeds the existing field lengths, or a specified field is missing from either
+                        new or live data.
+
+        Returns:
+            int: Number of features updated
+        """
+
+        self._class_logger.info(
+            'Updating layer `%s` in itemid `%s` in-place', self.layer_index, self.feature_service_itemid
+        )
+        self._class_logger.debug('Updating fields %s', self.fields)
+
+        #: Add null geometries if update_geometry==False so that we can create a featureset from the dataframe
+        #: (geometries will be ignored by upsert call)
+        if not update_geometry:
+            self._class_logger.debug('Attribute-only update; inserting null geometries')
+            self.new_dataframe['SHAPE'] = utils.get_null_geometries(self.feature_layer.properties)
+
+        #: Field checks to prevent Error: 400 errors from AGOL
+        field_checker = utils.FieldChecker(self.feature_layer.properties, self.new_dataframe)
+        field_checker.check_live_and_new_field_types_match(self.fields)
+        field_checker.check_for_non_null_fields(self.fields)
+        field_checker.check_field_length(self.fields)
+        field_checker.check_fields_present(self.fields, add_oid=True)
+        field_checker.check_srs_match()
+        #: FIXME: remove once Esri fixes https://github.com/Esri/arcgis-python-api/issues/1281
+        field_checker.check_for_empty_float_fields(self.fields)
+
+        #: Upsert
+        append_count = self._upsert_data(
+            self.feature_layer,
+            self.new_dataframe,
+            upsert=True,
+            upsert_matching_field='OBJECTID',
+            append_fields=self.fields,  #: Apparently this works if append_fields is all the fields, but not a subset?
+            update_geometry=update_geometry
+        )
+        return append_count
+
+    #: TODO: rename this method? not everything is an upsert
+    def _upsert_data(self, target_featurelayer, dataframe, **append_kwargs):
+        """UPdate and inSERT data into live dataset with featurelayer.append()
+
+        Note: The call to to_featureset() in this method will add new OBJECTIDs to the new data if they aren't already present. If
+
+        Args:
+            target_featurelayer (arcgis.features.FeatureLayer): Live dataset
+            dataframe (pd.DataFrame): Spatially-enabled dataframe containing new data. Column names must match live
+            data. New data must have a valid shape column
+            append_kwargs (keyword arguments): Arguments to be passed to .append()
+
+        Raises:
+            RuntimeError: If append fails; will attempt to rollback appends that worked.
+
+        Returns:
+            dict: Messages returned from append operation
+        """
+
+        try:
+            if append_kwargs['upsert'] \
+                and (
+                        append_kwargs['upsert_matching_field'] not in append_kwargs['append_fields']
+                        or
+                        append_kwargs['upsert_matching_field'] not in dataframe.columns
+                    ):
+                raise ValueError(
+                    f'Upsert matching field {append_kwargs["upsert_matching_field"]} not found in either append fields or existing fields.'
+                )
+        except KeyError:
+            pass
+
+        #: FIXME: With chunking, rollback could leave the data in an inconsistent state if a chunk fails midway through the process. All the chunks before that won't be rolled back, so some of the data will be new and some will be old. I suppose this will be ok for upserts, where the data will just get updated again. However, adds will probably just create duplicates of the chunks that aren't rolled back if we try to re-do the whole add op again. Do we create a list of dataframe row indices that were in the successful chunks?
+
+        running_append_total = 0
+        geojsons = utils.Chunking.build_upload_json(dataframe, target_featurelayer.properties.fields, 20_000_000)
+        self._class_logger.info('Appending/upserting data in %s chunk(s)', len(geojsons))
+        chunk_sizes = []
+        for chunk, geojson in enumerate(geojsons, 1):
+            try:
+                chunk_start = datetime.now()
+                chunk_size = sys.getsizeof(geojson.encode('utf-16'))
+                chunk_sizes.append(chunk_size)
+                self._class_logger.debug('Uploading chunk %s of %s, %s bytes', chunk, len(geojsons), chunk_size)
+                result, messages = utils.retry(
+                    target_featurelayer.append,
+                    upload_format='geojson',
+                    edits=geojson,
+                    return_messages=True,
+                    rollback=True,
+                    **append_kwargs
+                )
+                self._class_logger.debug(messages)
+                self._class_logger.debug('Chunk time: %s', datetime.now() - chunk_start)
+            except Exception:
+                self._class_logger.debug(pd.Series(chunk_sizes).describe())
+                self._class_logger.error('Append failed, feature service may be dirty due to append chunking.')
+                raise
+            if not result:
+                self._class_logger.debug(pd.Series(chunk_sizes).describe())
+                raise RuntimeError(
+                    f'Failed to append data at chunk {chunk} of {len(geojsons)}. Append operation should have been rolled back.'
+                )
+
+            running_append_total += messages['recordCount']
+
+        self._class_logger.debug(pd.Series(chunk_sizes).describe())
+        return running_append_total
+
+    def _truncate_and_load_data(self):
+        """Overwrite a layer by truncating and loading new data
+
+        Raises:
+            RuntimeError: If loading fails and reloading of old data fails (old data will be written to disk)
+
+        Returns:
+            int: Number of features loaded
+        """
+
+        self._class_logger.info(
+            'Truncating and loading layer `%s` in itemid `%s`', self.layer_index, self.feature_service_itemid
+        )
+        start = datetime.now()
+
+        #: Save the data to disk if failsafe dir provided
+        #: TODO: return path programmatically so client can catch exception and try to reload automatically?
+        if self.failsafe_dir:
+            self._class_logger.info('Saving existing data to %s', self.failsafe_dir)
+            saved_layer_path = utils.save_feature_layer_to_json(self.feature_layer, self.failsafe_dir)
+
+        #: Field checks to prevent Error: 400 errors from AGOL
+        field_checker = utils.FieldChecker(self.feature_layer.properties, self.new_dataframe)
+        field_checker.check_live_and_new_field_types_match(self.fields)
+        field_checker.check_for_non_null_fields(self.fields)
+        field_checker.check_field_length(self.fields)
+        field_checker.check_fields_present(self.fields, add_oid=False)
+        field_checker.check_srs_match()
+        #: FIXME: remove once Esri fixes https://github.com/Esri/arcgis-python-api/issues/1281
+        field_checker.check_for_empty_float_fields(self.fields)
+
+        self._class_logger.info('Truncating existing features...')
+        self._truncate_existing_data()
+
+        try:
+            self._class_logger.info('Loading new data...')
+            append_count = self._upsert_data(self.feature_layer, self.new_dataframe, upsert=False)
+            self._class_logger.debug('Total truncate and load time: %s', datetime.now() - start)
+        except Exception:
+            if self.failsafe_dir:
+                self._class_logger.error(
+                    'Append failed, feature service may be dirty due to append chunking. Data saved to %s',
+                    saved_layer_path
+                )
+                raise
+            self._class_logger.error(
+                'Append failed, feature service may be dirty due to append chunking. Old data not saved (no failsafe dir set)'
+            )
+            raise
+
+        return append_count
+
+    def _truncate_existing_data(self):
+        """Remove all existing features from the live dataset
+
+        Raises:
+            RuntimeError: If the truncate fails
+
+        Returns:
+            pd.DataFrame: The feature layer's data as a spatially-enabled dataframe prior to truncating
+        """
+
+        self._class_logger.debug('Truncating...')
+        truncate_result = utils.retry(self.feature_layer.manager.truncate, asynchronous=True, wait=True)
+        self._class_logger.debug(truncate_result)
+        if truncate_result['status'] != 'Completed':
+            raise RuntimeError(
+                f'Failed to truncate existing data from layer id {self.layer_index} in itemid {self.feature_service_itemid}'
+            )
+
+
+class FeatureServiceAttachmentsUpdater:
+    """Add or overwrite attachments in a feature service using a dataframe of the desired "new" attachments. Uses a
+    join field present in both live data and the attachments dataframe to match attachments with live data.
+    """
+
+    def __init__(self, gis):
+        self.gis = gis
+        self._class_logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
+        self.failed_dict = {}
+
+    def _get_live_oid_and_guid_from_join_field_values(self, live_features_as_df, attachment_join_field, attachments_df):
+        """Get the live Object ID and guid from the live features for only the features in attachments_df
+
+        Args:
+            live_features_as_df (pd.DataFrame): Spatial dataframe of all the feature layer's live data from AGOL
+            attachment_join_field (str): Column in attachments_df to use as a join key with live data
+            attachments_df (pd.DataFrame): New attachment data, including the join key and a path to the "new"
+                                           attachment
+
+        Returns:
+            pd.DataFrame: Attachments dataframe with corresponding live OIDs and GUIDs.
+        """
+
+        self._class_logger.debug('Using %s as the join field between live and new data', attachment_join_field)
+        subset_df = live_features_as_df.reindex(columns=['OBJECTID', 'GlobalID', attachment_join_field])
+        merged_df = subset_df.merge(attachments_df, on=attachment_join_field, how='inner')
+        self._class_logger.debug('%s features common to both live and new data', len(merged_df.index))
+
+        return merged_df
+
+    def _get_current_attachment_info_by_oid(self, live_data_subset_df):
+        """Merge the live attachment data using the ObjectID as the join
+
+        Args:
+            live_data_subset_df (pd.DataFrame): Live data with 'OBJECTID', 'GlobalID', and new attachment data
+
+        Returns:
+            pd.DataFrame: Live and new attachment data in one dataframe
+        """
+
+        live_attachments_df = pd.DataFrame(self.feature_layer.attachments.search())
+        live_attachments_subset_df = live_attachments_df.reindex(columns=['PARENTOBJECTID', 'NAME', 'ID'])
+        merged_df = live_data_subset_df.merge(
+            live_attachments_subset_df, left_on='OBJECTID', right_on='PARENTOBJECTID', how='left'
+        )
+        #: Cast ID field to nullable int to avoid conversion to float for np.nans
+        merged_df['ID'] = merged_df['ID'].astype('Int64')
+
+        return merged_df
+
+    def _create_attachment_action_df(self, attachment_eval_df, attachment_path_field):
+        """Create a dataframe containing the action needed for each feature resulting from the attachment join.
+
+        If the live feature doesn't have an attachment, add the attachment. If it does, compare the file names and only
+        attach if they are different. Otherwise, leave null.
+
+        Args:
+            attachment_eval_df (pd.DataFrame): DataFrame of live attachment data, subsetted to features that matched
+                                               the join key in the new attachments
+            attachment_path_field (str): The column that holds the attachment path
+
+        Returns:
+            pd.DataFrame: attachment_eval_df with 'operation' and 'new_filename' columns added
+        """
+
+        #: Get the file name from the full path
+        attachment_eval_df['new_filename'] = attachment_eval_df[attachment_path_field].apply(
+            lambda path: Path(path).name
+        )
+
+        #: Overwrite if different names, add if no existing name, do nothing if names are the same
+        attachment_eval_df['operation'] = np.nan
+        attachment_eval_df.loc[attachment_eval_df['NAME'] != attachment_eval_df['new_filename'],
+                               'operation'] = 'overwrite'
+        attachment_eval_df.loc[attachment_eval_df['NAME'].isna(), 'operation'] = 'add'
+
+        value_counts = attachment_eval_df['operation'].value_counts(dropna=False)
+        for operation in ['add', 'overwrite', np.nan]:
+            if operation not in value_counts:
+                value_counts[operation] = 0
+        self._class_logger.debug(
+            'Calculated attachment operations: adds: %s, overwrites: %s, none: %s', value_counts['add'],
+            value_counts['overwrite'], value_counts[np.nan]
+        )
+
+        return attachment_eval_df
+
+    def _add_attachments_by_oid(self, attachment_action_df, attachment_path_field):
+        """Add attachments using the feature's OID based on the 'operation' field of the dataframe
+
+        Args:
+            attachment_action_df (pd.DataFrame): A dataframe containing 'operation', 'OBJECTID', and
+                                                 attachment_path_field columns
+            attachment_path_field (str): The column that holds the attachment path
+
+        Returns:
+            int: The number of features that successfully have attachments added.
+        """
+
+        adds_dict = attachment_action_df[attachment_action_df['operation'] == 'add'].to_dict(orient='index')
+        adds_count = 0
+
+        for row in adds_dict.values():
+            target_oid = row['OBJECTID']
+            filepath = row[attachment_path_field]
+
+            self._class_logger.debug('Add %s to OID %s', filepath, target_oid)
+            try:
+                result = self.feature_layer.attachments.add(target_oid, filepath)
+            except Exception:
+                self._class_logger.error('AGOL error while adding %s to OID %s', filepath, target_oid, exc_info=True)
+                self.failed_dict[target_oid] = ('add', filepath)
+                continue
+
+            self._class_logger.debug('%s', result)
+            if not result['addAttachmentResult']['success']:
+                warnings.warn(f'Failed to attach {filepath} to OID {target_oid}')
+                self.failed_dict[target_oid] = ('add', filepath)
+                continue
+
+            adds_count += 1
+
+        return adds_count
+
+    def _overwrite_attachments_by_oid(self, attachment_action_df, attachment_path_field):
+        """Overwrite attachments using the feature's OID based on the 'operation' field of the dataframe
+
+        Args:
+            attachment_action_df (pd.DataFrame): A dataframe containing 'operation', 'OBJECTID', 'ID', 'NAME', and
+                                                 attachment_path_field columns
+            attachment_path_field (str): The column that holds the attachment path
+
+        Returns:
+            int: The number of features that successfully have their attachments overwritten.
+        """
+
+        overwrites_dict = attachment_action_df[attachment_action_df['operation'] == 'overwrite'].to_dict(orient='index')
+        overwrites_count = 0
+
+        for row in overwrites_dict.values():
+            target_oid = row['OBJECTID']
+            filepath = row[attachment_path_field]
+            attachment_id = row['ID']
+            old_name = row['NAME']
+
+            self._class_logger.debug(
+                'Overwriting %s (attachment ID %s) on OID %s with %s', old_name, attachment_id, target_oid, filepath
+            )
+            try:
+                result = self.feature_layer.attachments.update(target_oid, attachment_id, filepath)
+            except Exception:
+                self._class_logger.error(
+                    'AGOL error while overwriting %s (attachment ID %s) on OID %s with %s',
+                    old_name,
+                    attachment_id,
+                    target_oid,
+                    filepath,
+                    exc_info=True
+                )
+                self.failed_dict[target_oid] = ('update', filepath)
+                continue
+
+            if not result['updateAttachmentResult']['success']:
+                warnings.warn(
+                    f'Failed to update {old_name}, attachment ID {attachment_id}, on OID {target_oid} with {filepath}'
+                )
+                self.failed_dict[target_oid] = ('update', filepath)
+                continue
+
+            overwrites_count += 1
+
+        return overwrites_count
+
+    @staticmethod
+    def _check_attachment_dataframe_for_invalid_column_names(attachment_dataframe, invalid_names):
+        invalid_names_index = pd.Index(invalid_names)
+        intersection = attachment_dataframe.columns.intersection(invalid_names_index)
+        if not intersection.empty:
+            raise RuntimeError(f'Attachment dataframe contains the following invalid names: {list(intersection)}')
+
+    def update_attachments(
+        self, feature_layer_itemid, attachment_join_field, attachment_path_field, attachments_df, layer_number=0
+    ):
+        """Update a feature layer's attachments based on info from a dataframe of desired attachment file names
+
+        Depends on a dataframe populated with a join key for the live data and the downloaded or locally-available
+        attachments. If the name of the "new" attachment is the same as an existing attachment for that feature, it is
+        not updated. If it is different or there isn't an existing attachment, the "new" attachment is attached to that
+        feature.
+
+        Args:
+            feature_layer_itemid (str): The AGOL Item ID of the feature layer to update
+            attachment_join_field (str): The field containing the join key between the attachments dataframe and the
+                                         live data
+            attachment_path_field (str): The field containing the desired attachment file path
+            attachments_df (pd.DataFrame): A dataframe of desired attachments, including a join key and the local path
+                                           to the attachment
+            layer_number (int, optional): The layer within the Item ID to update. Defaults to 0.
+
+        Returns:
+            (int, int): Tuple of counts of successful overwrites and adds.
+        """
+
+        self._class_logger.info('Updating attachments...')
+        #: These names are present in the live attachment data downloaded from AGOL. Because we merge the dataframes
+        #: later, we need to make sure they're not the same. There may be better ways of handling this that allows the
+        #: client names to be preserved, but for now force them to fix this.
+        self._check_attachment_dataframe_for_invalid_column_names(
+            attachments_df, invalid_names=['OBJECTID', 'PARENTOBJECTID', 'NAME', 'ID']
+        )
+        self._class_logger.debug('Using layer %s from item ID %s', layer_number, feature_layer_itemid)
+        self.feature_layer = self.gis.content.get(feature_layer_itemid).layers[layer_number]
+        live_features_as_df = pd.DataFrame.spatial.from_layer(self.feature_layer)
+        live_data_subset_df = self._get_live_oid_and_guid_from_join_field_values(
+            live_features_as_df, attachment_join_field, attachments_df
+        )
+        #: TODO: Make sure layer supports attachments so we don't get an arcgis error.
+        #: Check out the feature layer .properties and FeatureLayerManager.add_to_definition to check/enable?
+        attachment_eval_df = self._get_current_attachment_info_by_oid(live_data_subset_df)
+        attachment_action_df = self._create_attachment_action_df(attachment_eval_df, attachment_path_field)
+
+        overwrites_count = self._overwrite_attachments_by_oid(attachment_action_df, attachment_path_field)
+        adds_count = self._add_attachments_by_oid(attachment_action_df, attachment_path_field)
+        self._class_logger.info('%s attachments added, %s attachments overwritten', adds_count, overwrites_count)
+
+        return overwrites_count, adds_count
+
+    @staticmethod
+    def build_attachments_dataframe(input_dataframe, join_column, attachment_column, out_dir):
+        """Create an attachments dataframe by subsetting down to just the two fields and dropping any rows
+           with null/empty attachments
+
+        Args:
+            input_dataframe (pd.DataFrame): Input data containing at least the join and attachment filename columns
+            join_column (str): Unique key joining attachments to live data
+            attachment_column (str): Filename for each attachment
+            out_dir (str or Path): Output directory, will be used to build full path to attachment
+
+        Returns:
+            pd.DataFrame: Dataframe with join key, attachment name, and full attachment paths
+        """
+
+        input_dataframe[attachment_column].replace('', np.nan, inplace=True)  #: pandas doesn't see empty strings as NAs
+        attachments_dataframe = input_dataframe[[join_column, attachment_column]] \
+                                            .copy().dropna(subset=[attachment_column])
+        #: Create the full path by prepending the output directory using .apply and a lambda function
+        attachments_dataframe['full_file_path'] = attachments_dataframe[attachment_column] \
+                                                    .apply(lambda filename: str(Path(out_dir, filename)))
+
+        return attachments_dataframe
+
+
+class ColorRampReclassifier:
+    """Updates the interval ranges on a layer's classification renderer based on the layer's current data.
+    """
+
+    def __init__(self, webmap_item, gis):
+        self.webmap_item = webmap_item
+        self.gis = gis
+        self._class_logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
+
+    def _get_layer_dataframe(self, layer_name, feature_layer_number=0):
+        """Create a dataframe from layer_name in self.webmap_item
+
+        Args:
+            layer_name (str): The exact name of the layer
+            feature_layer_number (int): The number of the layer with the feature service to update. Defaults to 0.
+
+        Returns:
+            spatially-enabled data frame: The layer's data, including geometries.
+        """
+
+        self._class_logger.info('Getting dataframe from `%s` on `%s`', layer_name, self.webmap_item.title)
+        webmap_object = arcgis.mapping.WebMap(self.webmap_item)
+        layer = webmap_object.get_layer(title=layer_name)
+        feature_layer = self.gis.content.get(layer['itemId'])
+        layer_dataframe = pd.DataFrame.spatial.from_layer(feature_layer.layers[feature_layer_number])
+
+        return layer_dataframe
+
+    def _get_layer_id(self, layer_name):
+        """Get the ID number of layer_name in self.webmap_item
+
+        Args:
+            layer_name (str): The exact name of the layer
+
+        Raises:
+            ValueError: If the layer is not found in the webmap
+
+        Returns:
+            int: The index (0-based) of the the layer in the web map
+        """
+
+        data = self.webmap_item.get_data()
+        for layer_id, layer in enumerate(data['operationalLayers']):
+            if layer['title'] == layer_name:
+                self._class_logger.debug('Layer `%s` has id `%s`', layer_name, layer_id)
+                return layer_id
+
+        #: If we haven't matched the title and returned a valid id, raise an error.
+        raise ValueError(f'Could not find "{layer_name}" in {self.webmap_item.title}')
+
+    @staticmethod
+    def _calculate_new_stops(dataframe, column, stops):
+        """Calculate new stop values for an AGOL color ramp using what appears to be AGOL's method for unclassed ramps.
+
+        Args:
+            dataframe (pd.DataFrame): Data being classified
+            column (str): Column to classify
+            stops (int, optional): Number of stops to create.
+
+        Returns:
+            List: New stops cast as ints
+        """
+
+        if column not in dataframe.columns:
+            raise ValueError(f'Column `{column}` not in dataframe')
+        minval = dataframe[column].min()
+        mean = dataframe[column].mean()
+        std_dev = dataframe[column].std()
+        upper = mean + std_dev  #: AGOL's default upper value for unclassed ramps seems to be mean + 1 std dev
+
+        new_stops = np.linspace(minval, upper, stops)
+        new_stops_ints = [int(stop) for stop in new_stops]
+
+        return new_stops_ints
+
+    def _update_stop_values(self, layer_number, new_stops):
+        """Update the stop values of an (un)classified polygon renderer in an AGOL Webmap
+
+        Args:
+            layer_number (int): The index for the layer to be updated
+            new_stops (List): New values for the existing stops
+
+        Returns:
+            Bool: Success or failure of update operation
+        """
+
+        #: Get short reference to the stops dictionary from the webmap's data json
+        data = self.webmap_item.get_data()
+        renderer = data['operationalLayers'][layer_number]['layerDefinition']['drawingInfo']['renderer']
+        stops = renderer['visualVariables'][0]['stops']
+
+        #: Overwrite the value, update the webmap item
+        for stop, new_value in zip(stops, new_stops):
+            stop['value'] = new_value
+        self._class_logger.info(
+            'Updating stop values on layer number `%s` in `%s`', layer_number, self.webmap_item.title
+        )
+        result = self.webmap_item.update(item_properties={'text': json.dumps(data)})
+        self._class_logger.debug('Update result: %s', result)
+
+        return result
+
+    def update_color_ramp_values(self, layer_name, column_name, stops=5):
+        """Update the color ramp ranges for layer_name in self.webmap_item.
+
+        Does not alter colors or introduce additional stops; only overwrites the values for existing breaks.
+
+        Args:
+            layer_name (str): The exact name of the layer to be updated
+            column_name (str): The name of the attribute being displayed as an (un)classified range
+            stops (int, optional): The number of stops to calculate. Must match existing stops. Defaults to 5.
+
+        Returns:
+            Bool: Success or failure of update operation
+        """
+        layer_id = self._get_layer_id(layer_name)
+        dataframe = self._get_layer_dataframe(layer_name)
+        new_stops = self._calculate_new_stops(dataframe, column_name, stops)
+        result = self._update_stop_values(layer_id, new_stops)
+
+        return result
diff --git a/src/palletjack/transform.py b/src/palletjack/transform.py
index d75cce7..5141859 100644
--- a/src/palletjack/transform.py
+++ b/src/palletjack/transform.py
@@ -1,9 +1,10 @@
-"""transform.py: Processes in the Transfomation step of ETL
+"""transform.py: Processes in the Transformation step of ETL
 """
 import logging
 import warnings
 from datetime import datetime
 
+import arcgis
 import pandas as pd
 from arcgis import GeoAccessor, GeoSeriesAccessor
 
@@ -15,12 +16,17 @@
 class APIGeocoder:
     """Geocode using the UGRC Web API Geocoder.
 
-    Instantiate an APIGeocoder object with an api key from developer.mapserv.utah.gov
+    Instantiate an APIGeocoder object with an api key from developer.mapserv.utah.gov. It will attempt to validate the
+    API key. If it fails, it will raise one of the following:
+        RuntimeError: If there was a network or other error
+        ValueError: If the API responds with an invalid key message
+        UserWarning: If the API responds with some other abnormal result
     """
 
     def __init__(self, api_key):
         self.api_key = api_key
         self._class_logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
+        utils.Geocoding.validate_api_key(self.api_key)
 
     def geocode_dataframe(self, dataframe, street_col, zone_col, wkid, rate_limits=(0.015, 0.03), **api_args):
         """Geocode a pandas dataframe into a spatially-enabled dataframe
@@ -58,7 +64,7 @@ def geocode_dataframe(self, dataframe, street_col, zone_col, wkid, rate_limits=(
             if i % reporting_interval == 0:
                 self._class_logger.info('Geocoding row %s of %s, %s%%', i, dataframe_length, i / dataframe_length * 100)
             row_dict = row._asdict()
-            results = utils.geocode_addr(
+            results = utils.Geocoding.geocode_addr(
                 row_dict[street_col],
                 row_dict[zone_col],
                 self.api_key,
@@ -81,3 +87,39 @@ def geocode_dataframe(self, dataframe, street_col, zone_col, wkid, rate_limits=(
         except ZeroDivisionError:
             warnings.warn('Empty spatial dataframe after geocoding', RuntimeWarning)
         return spatial_dataframe
+
+
+class FeatureServiceMerging:
+
+    @staticmethod
+    def update_live_data_with_new_data(live_dataframe, new_dataframe, join_column):
+
+        try:
+            live_dataframe.set_index(join_column, inplace=True)
+            new_dataframe.set_index(join_column, inplace=True)
+        except KeyError as error:
+            raise ValueError('Join column not found in live or new dataframes') from error
+
+        indicator_dataframe = live_dataframe.merge(new_dataframe, on=join_column, how='outer', indicator=True)
+        new_only_dataframe = indicator_dataframe[indicator_dataframe['_merge'] == 'right_only']
+        if not new_only_dataframe.empty:
+            keys_not_found = list(new_only_dataframe.index)
+            warnings.warn(
+                f'The following keys from the new data were not found in the existing dataset: {keys_not_found}',
+                RuntimeWarning
+            )
+
+        live_dataframe.update(new_dataframe)
+        return (live_dataframe.reset_index().convert_dtypes())
+
+    @staticmethod
+    def get_live_dataframe(gis, feature_service_itemid, layer_index=0):
+        try:
+            feature_layer = arcgis.features.FeatureLayer.fromitem(
+                gis.content.get(feature_service_itemid), layer_id=layer_index
+            )
+            live_dataframe = feature_layer.query(as_df=True)
+        except Exception as error:
+            raise RuntimeError('Failed to load live dataframe') from error
+
+        return live_dataframe
diff --git a/src/palletjack/updaters.py b/src/palletjack/updaters.py
deleted file mode 100644
index 01f1292..0000000
--- a/src/palletjack/updaters.py
+++ /dev/null
@@ -1,819 +0,0 @@
-"""Objects for bringing data from SFTP-hosted CSVs into AGOL Feature Services
-"""
-
-import datetime
-import json
-import logging
-import warnings
-from pathlib import Path
-
-import arcgis
-import numpy as np
-import pandas as pd
-from arcgis.features import GeoAccessor, GeoSeriesAccessor
-
-from . import utils
-
-logger = logging.getLogger(__name__)
-
-
-class FeatureServiceInlineUpdater:
-    """Updates an AGOL Feature Service with data from a pandas DataFrame
-    """
-
-    def __init__(self, gis, dataframe, index_column, field_mapping=None):
-        self.gis = gis
-        if field_mapping:
-            dataframe = utils.rename_fields(dataframe, field_mapping)
-        self.new_dataframe = dataframe.rename(columns=utils.rename_columns_for_agol(dataframe.columns))
-        self.index_column = utils.rename_columns_for_agol([index_column])[index_column]
-        try:
-            self.new_data_as_dict = self.new_dataframe.set_index(self.index_column).to_dict('index')
-        except KeyError as error:
-            raise KeyError(f'Index column {index_column} not found in dataframe columns') from error
-        self._class_logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
-
-    def update_existing_features_in_feature_service_with_arcpy(self, feature_service_url, fields):
-        """Update a feature service in-place with data from pandas data frame using arcpy's UpdateCursor
-
-        Args:
-            feature_service_url (str): URL to feature service
-            fields (list): Names of fields to update
-        """
-
-        #: Put this here to enable the package to install/run without arcpy installed if desired
-        try:
-            import arcpy  # pylint: disable=import-outside-toplevel
-        except ImportError as error:
-            raise ImportError('Failure importing arcpy. ArcGIS Pro must be installed.') from error
-
-        self._class_logger.info('Updating `%s` in-place', feature_service_url)
-        self._class_logger.debug('Updating fields %s', fields)
-        rows_updated = 0
-        with arcpy.da.UpdateCursor(feature_service_url, fields) as update_cursor:
-            for row in update_cursor:
-                self._class_logger.debug('Evaluating row: %s', row)
-                key = row[0]
-                if key in self.new_data_as_dict:
-                    row[1:] = list(self.new_data_as_dict[key].values())
-                    self._class_logger.debug('Updating row: %s', row)
-                    try:
-                        update_cursor.updateRow(row)
-                        rows_updated += 1
-                    except RuntimeError as error:
-                        if 'The value type is incompatible with the field type' in str(error):
-                            raise ValueError('Field type mismatch between dataframe and feature service') from error
-        self._class_logger.info('%s rows updated', rows_updated)
-
-        return rows_updated
-
-    def _validate_working_fields_in_live_and_new_dataframes(self, live_dataframe, fields):
-        live_fields = set(live_dataframe.columns)
-        new_fields = set(self.new_dataframe.columns)
-        working_fields = set(fields)
-
-        live_dif = working_fields - live_fields
-        new_dif = working_fields - new_fields
-
-        if live_dif or new_dif:
-            raise RuntimeError(
-                f'Field mismatch between defined fields and either new or live data.\nFields not in live data: '
-                f'{live_dif}\nFields not in new data: {new_dif}'
-            )
-
-    def _get_common_rows(self, live_dataframe) -> pd.DataFrame:
-        """Create a dataframe containing only the rows common to both the existing live dataset and the new updates
-
-        Args:
-            live_dataframe (pd.Dataframe): The existing, live dataframe created from an AGOL hosted feature layer.
-
-        Returns:
-            pd.DataFrame: A new dataframe containing the common rows created by .merge()ing.
-        """
-
-        joined_dataframe = live_dataframe.merge(self.new_dataframe, on=[self.index_column], how='outer', indicator=True)
-        subset_dataframe = joined_dataframe[joined_dataframe['_merge'] == 'both'].copy()
-
-        not_found_dataframe = joined_dataframe[joined_dataframe['_merge'] == 'right_only'].copy()
-        if not not_found_dataframe.empty:
-            keys_not_found = list(not_found_dataframe[self.index_column])
-            self._class_logger.warning(
-                'The following keys from the new data were not found in the existing dataset: %s', keys_not_found
-            )
-
-        return subset_dataframe
-
-    def _clean_dataframe_columns(self, dataframe, fields) -> pd.DataFrame:
-        """Delete superfluous fields from dataframe that will be used for the .edit() operation
-
-        Removes or renames the '_x', '_y' fields that are created by dataframe.merge() as appropriate based on the
-        fields provided.
-
-        Args:
-            dataframe (pd.Dataframe): Dataframe to be cleaned
-            fields (list[str]): The fields to keep; all others will be deleted
-
-        Returns:
-            pd.DataFrame: A dataframe with only our desired columns.
-        """
-
-        #: rename specified fields, delete any other _x/_y fields (in effect, just keep the specified fields...)
-        #: TODO: There may be a simpler way to do this- maybe .reindex(fields)?
-        rename_dict = {f'{f}_y': f for f in fields}
-        renamed_dataframe = dataframe.rename(columns=rename_dict).copy()
-        fields_to_delete = [f for f in renamed_dataframe.columns if f.endswith(('_x', '_y'))]
-        fields_to_delete.append('_merge')
-        self._class_logger.debug('Deleting joined dataframe fields: %s', fields_to_delete)
-        return renamed_dataframe.drop(labels=fields_to_delete, axis='columns', errors='ignore').copy()
-
-    def _get_old_and_new_values(self, live_dict, object_ids):
-        """Create a dictionary of the old (existing, live) and new values based on a list of object ids
-
-        Args:
-            live_dict (dict): The old (existing, live) data, key is an arbitrary index, content is another dict keyed
-            by the field names.
-            object_ids (list[int]): The object ids to include in the new dictionary
-
-        Returns:
-            dict: {object_id:
-                    {'old_values': {data as dict from live_dict},
-                     'new_values': {data as dict from self.new_dataframe}
-                    },
-                  ...,
-                  }
-        """
-        oid_and_key_lookup = {
-            row['OBJECTID']: row[self.index_column] for _, row in live_dict.items() if row['OBJECTID'] in object_ids
-        }
-        old_values_by_oid = {row['OBJECTID']: row for _, row in live_dict.items() if row['OBJECTID'] in object_ids}
-        new_data_as_dict_preserve_key = self.new_dataframe.set_index(self.index_column, drop=False).to_dict('index')
-        new_values_by_oid = {
-            object_id: new_data_as_dict_preserve_key[key] for object_id, key in oid_and_key_lookup.items()
-        }
-
-        combined_values_by_oid = {
-            object_id: {
-                'old_values': old_values_by_oid[object_id],
-                'new_values': new_values_by_oid[object_id]
-            } for object_id in oid_and_key_lookup
-        }
-
-        return combined_values_by_oid
-
-    def _parse_results(self, results_dict, live_dataframe) -> int:
-        """Integrate .edit() results with data for reporting purposes. Relies on _get_old_and_new_values().
-
-        Args:
-            results_dict (dict): AGOL response as a python dict (raw output from .edit_features(). Defined in
-            https://developers.arcgis.com/rest/services-reference/enterprise/apply-edits-feature-service-layer-.htm,
-            where `true`/`false` are python True/False.
-            live_dataframe (pd.DataFrame): Existing/live dataframe created from hosted feature layer.
-
-        Returns:
-            int: Number of edits successfully applied. If any failed, rollback will cause this to be set to 0.
-        """
-
-        live_dataframe_as_dict = live_dataframe.to_dict('index')
-        update_results = results_dict['updateResults']
-        if not update_results:
-            self._class_logger.info('No update results returned; no updates attempted')
-            return 0
-        update_successes = [result['objectId'] for result in update_results if result['success']]
-        update_failures = [result['objectId'] for result in update_results if not result['success']]
-        rows_updated = len(update_successes)
-        if update_successes:
-            self._class_logger.info('%s rows successfully updated:', len(update_successes))
-            for _, data in self._get_old_and_new_values(live_dataframe_as_dict, update_successes).items():
-                self._class_logger.debug('Existing data: %s', data['old_values'])
-                self._class_logger.debug('New data: %s', data['new_values'])
-        if update_failures:
-            self._class_logger.warning(
-                'The following %s updates failed. As a result, all successful updates should have been rolled back.',
-                len(update_failures)
-            )
-            for _, data in self._get_old_and_new_values(live_dataframe_as_dict, update_failures).items():
-                self._class_logger.warning('Existing data: %s', data['old_values'])
-                self._class_logger.warning('New data: %s', data['new_values'])
-            rows_updated = 0
-
-        return rows_updated
-
-    def update_existing_features_in_hosted_feature_layer(self, feature_layer_itemid, fields) -> int:
-        """Update existing features with new attribute data in the defined fields using arcgis instead of arcpy.
-
-        Relies on new data from self.new_dataframe. Uses the ArcGIS API for Python's .edit_features() method on a
-        hosted feature layer item. May be fragile for large datasets, per .edit_features() documentation. Can't get
-        .append() working properly.
-
-        Args:
-            feature_layer_itemid (str): The AGOL item id of the hosted feature layer to update.
-            fields (list[str]): The field names in the feature layer to update.
-
-        Returns:
-            int: Number of features successfully updated (any failures will cause rollback and should return 0)
-        """
-
-        self._class_logger.info('Updating itemid `%s` in-place', feature_layer_itemid)
-        self._class_logger.debug('Updating fields %s', fields)
-        feature_layer_item = self.gis.content.get(feature_layer_itemid)
-        feature_layer = arcgis.features.FeatureLayer.fromitem(feature_layer_item)
-        live_dataframe = pd.DataFrame.spatial.from_layer(feature_layer)
-        self._validate_working_fields_in_live_and_new_dataframes(live_dataframe, fields)
-        subset_dataframe = self._get_common_rows(live_dataframe)
-        if subset_dataframe.empty:
-            self._class_logger.warning(
-                'No matching rows between live dataset and new dataset based on field `%s`', self.index_column
-            )
-            return 0
-        cleaned_dataframe = self._clean_dataframe_columns(subset_dataframe, fields)
-        results = feature_layer.edit_features(
-            updates=cleaned_dataframe.spatial.to_featureset(), rollback_on_failure=True
-        )
-        log_fields = ['OBJECTID']
-        log_fields.extend(fields)
-        number_of_rows_updated = self._parse_results(results, live_dataframe[log_fields])
-        return number_of_rows_updated
-
-    def _upsert_new_data(self, target_featurelayer, dataframe, feature_service_item_id, layer_index):
-        """UPdate and inSERT new data to live dataset with featurelayer.append()
-
-        Args:
-            target_featurelayer (arcgis.features.FeatureLayer): Live dataset
-            dataframe (pd.DataFrame): Spatially-enabled dataframe containing new data. Column names should match live
-            data. New data must have a valid shape column
-            feature_service_item_id (int): Layer ID within the main feature service
-            layer_index (str): AGOL item ID for the main feature service
-
-        Raises:
-            RuntimeError: If append fails; will attempt to rollback appends that worked.
-
-        Returns:
-            dict: Messages returned from append operation
-        """
-
-        geojson = dataframe.spatial.to_featureset().to_geojson
-        result, messages = utils.retry(
-            target_featurelayer.append,
-            upload_format='geojson',
-            edits=geojson,
-            upsert=True,
-            return_messages=True,
-            rollback=True,
-            upsert_matching_field=self.index_column
-        )
-
-        self._class_logger.debug(messages)
-        if not result:
-            raise RuntimeError(
-                f'Failed to append data to layer id {layer_index} in itemid {feature_service_item_id}. Append should'
-                ' have been rolled back.'
-            )
-
-        return messages
-
-    def upsert_new_data_in_hosted_feature_layer(self, feature_service_item_id, layer_index=0):
-        """UPdate existing data and inSERT new data to a hosted feature layer from a spatially-enabled data frame
-
-        Relies on the FeatureServiceInlineUpdater's self.new_dataframe for the new data and self.index_column to define
-        the common column used to match new and existing data. The field specified by self.index_column must have a
-        "unique constraint" in the target feature layer (ie, it must be indexed and be unique).
-
-        Args:
-            feature_service_item_id (arcgis.features.FeatureLayer): The target feature layer
-            layer_index (int, optional): The layer id within the target feature layer. Defaults to 0.
-
-        Raises:
-            RuntimeError: If append fails; will attempt to rollback appends that worked.
-            RuntimeError: If new data contains a field not present in the live data
-            Warning: If live data contains a field not present in the new data
-            RuntimeError: If index_column is not in featurelayer's fields
-            RuntimeError: If the field is not unique (or if it's indexed but not unique)
-
-        Returns:
-            int: The number of records updated
-        """
-
-        target_featurelayer = arcgis.features.FeatureLayer.fromitem(
-            self.gis.content.get(feature_service_item_id), layer_id=layer_index
-        )
-        #: temp fix until Esri fixes empty series as NaN bug
-        fixed_dataframe = utils.replace_nan_series_with_empty_strings(self.new_dataframe)
-        utils.check_fields_match(target_featurelayer, fixed_dataframe)
-        utils.check_index_column_in_feature_layer(target_featurelayer, self.index_column)
-        utils.check_field_set_to_unique(target_featurelayer, self.index_column)
-        self._class_logger.info('Loading new data...')
-        messages = self._upsert_new_data(target_featurelayer, fixed_dataframe, feature_service_item_id, layer_index)
-
-        return messages['recordCount']
-
-
-class FeatureServiceAttachmentsUpdater:
-    """Add or overwrite attachments in a feature service using a dataframe of the desired "new" attachments. Uses a
-    join field present in both live data and the attachments dataframe to match attachments with live data.
-    """
-
-    def __init__(self, gis):
-        self.gis = gis
-        self._class_logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
-        self.failed_dict = {}
-
-    def _get_live_oid_and_guid_from_join_field_values(self, live_features_as_df, attachment_join_field, attachments_df):
-        """Get the live Object ID and guid from the live features for only the features in attachments_df
-
-        Args:
-            live_features_as_df (pd.DataFrame): Spatial dataframe of all the feature layer's live data from AGOL
-            attachment_join_field (str): Column in attachments_df to use as a join key with live data
-            attachments_df (pd.DataFrame): New attachment data, including the join key and a path to the "new"
-                                           attachment
-
-        Returns:
-            pd.DataFrame: Attachments dataframe with corresponding live OIDs and GUIDs.
-        """
-
-        self._class_logger.debug('Using %s as the join field between live and new data', attachment_join_field)
-        subset_df = live_features_as_df.reindex(columns=['OBJECTID', 'GlobalID', attachment_join_field])
-        merged_df = subset_df.merge(attachments_df, on=attachment_join_field, how='inner')
-        self._class_logger.debug('%s features common to both live and new data', len(merged_df.index))
-
-        return merged_df
-
-    def _get_current_attachment_info_by_oid(self, live_data_subset_df):
-        """Merge the live attachment data using the ObjectID as the join
-
-        Args:
-            live_data_subset_df (pd.DataFrame): Live data with 'OBJECTID', 'GlobalID', and new attachment data
-
-        Returns:
-            pd.DataFrame: Live and new attachment data in one dataframe
-        """
-
-        live_attachments_df = pd.DataFrame(self.feature_layer.attachments.search())
-        live_attachments_subset_df = live_attachments_df.reindex(columns=['PARENTOBJECTID', 'NAME', 'ID'])
-        merged_df = live_data_subset_df.merge(
-            live_attachments_subset_df, left_on='OBJECTID', right_on='PARENTOBJECTID', how='left'
-        )
-        #: Cast ID field to nullable int to avoid conversion to float for np.nans
-        merged_df['ID'] = merged_df['ID'].astype('Int64')
-
-        return merged_df
-
-    def _create_attachment_action_df(self, attachment_eval_df, attachment_path_field):
-        """Create a dataframe containing the action needed for each feature resulting from the attachment join.
-
-        If the live feature doesn't have an attachment, add the attachment. If it does, compare the file names and only
-        attach if they are different. Otherwise, leave null.
-
-        Args:
-            attachment_eval_df (pd.DataFrame): DataFrame of live attachment data, subsetted to features that matched
-                                               the join key in the new attachments
-            attachment_path_field (str): The column that holds the attachment path
-
-        Returns:
-            pd.DataFrame: attachment_eval_df with 'operation' and 'new_filename' columns added
-        """
-
-        #: Get the file name from the full path
-        attachment_eval_df['new_filename'] = attachment_eval_df[attachment_path_field].apply(
-            lambda path: Path(path).name
-        )
-
-        #: Overwrite if different names, add if no existing name, do nothing if names are the same
-        attachment_eval_df['operation'] = np.nan
-        attachment_eval_df.loc[attachment_eval_df['NAME'] != attachment_eval_df['new_filename'],
-                               'operation'] = 'overwrite'
-        attachment_eval_df.loc[attachment_eval_df['NAME'].isna(), 'operation'] = 'add'
-
-        value_counts = attachment_eval_df['operation'].value_counts(dropna=False)
-        for operation in ['add', 'overwrite', np.nan]:
-            if operation not in value_counts:
-                value_counts[operation] = 0
-        self._class_logger.debug(
-            'Calculated attachment operations: adds: %s, overwrites: %s, none: %s', value_counts['add'],
-            value_counts['overwrite'], value_counts[np.nan]
-        )
-
-        return attachment_eval_df
-
-    def _add_attachments_by_oid(self, attachment_action_df, attachment_path_field):
-        """Add attachments using the feature's OID based on the 'operation' field of the dataframe
-
-        Args:
-            attachment_action_df (pd.DataFrame): A dataframe containing 'operation', 'OBJECTID', and
-                                                 attachment_path_field columns
-            attachment_path_field (str): The column that holds the attachment path
-
-        Returns:
-            int: The number of features that successfully have attachments added.
-        """
-
-        adds_dict = attachment_action_df[attachment_action_df['operation'] == 'add'].to_dict(orient='index')
-        adds_count = 0
-
-        for row in adds_dict.values():
-            target_oid = row['OBJECTID']
-            filepath = row[attachment_path_field]
-
-            self._class_logger.debug('Add %s to OID %s', filepath, target_oid)
-            try:
-                result = self.feature_layer.attachments.add(target_oid, filepath)
-            except Exception:
-                self._class_logger.error('AGOL error while adding %s to OID %s', filepath, target_oid, exc_info=True)
-                self.failed_dict[target_oid] = ('add', filepath)
-                continue
-
-            self._class_logger.debug('%s', result)
-            if not result['addAttachmentResult']['success']:
-                warnings.warn(f'Failed to attach {filepath} to OID {target_oid}')
-                self.failed_dict[target_oid] = ('add', filepath)
-                continue
-
-            adds_count += 1
-
-        return adds_count
-
-    def _overwrite_attachments_by_oid(self, attachment_action_df, attachment_path_field):
-        """Overwrite attachments using the feature's OID based on the 'operation' field of the dataframe
-
-        Args:
-            attachment_action_df (pd.DataFrame): A dataframe containing 'operation', 'OBJECTID', 'ID', 'NAME', and
-                                                 attachment_path_field columns
-            attachment_path_field (str): The column that holds the attachment path
-
-        Returns:
-            int: The number of features that successfully have their attachments overwritten.
-        """
-
-        overwrites_dict = attachment_action_df[attachment_action_df['operation'] == 'overwrite'].to_dict(orient='index')
-        overwrites_count = 0
-
-        for row in overwrites_dict.values():
-            target_oid = row['OBJECTID']
-            filepath = row[attachment_path_field]
-            attachment_id = row['ID']
-            old_name = row['NAME']
-
-            self._class_logger.debug(
-                'Overwriting %s (attachment ID %s) on OID %s with %s', old_name, attachment_id, target_oid, filepath
-            )
-            try:
-                result = self.feature_layer.attachments.update(target_oid, attachment_id, filepath)
-            except Exception:
-                self._class_logger.error(
-                    'AGOL error while overwriting %s (attachment ID %s) on OID %s with %s',
-                    old_name,
-                    attachment_id,
-                    target_oid,
-                    filepath,
-                    exc_info=True
-                )
-                self.failed_dict[target_oid] = ('update', filepath)
-                continue
-
-            if not result['updateAttachmentResult']['success']:
-                warnings.warn(
-                    f'Failed to update {old_name}, attachment ID {attachment_id}, on OID {target_oid} with {filepath}'
-                )
-                self.failed_dict[target_oid] = ('update', filepath)
-                continue
-
-            overwrites_count += 1
-
-        return overwrites_count
-
-    @staticmethod
-    def _check_attachment_dataframe_for_invalid_column_names(attachment_dataframe, invalid_names):
-        invalid_names_index = pd.Index(invalid_names)
-        intersection = attachment_dataframe.columns.intersection(invalid_names_index)
-        if not intersection.empty:
-            raise RuntimeError(f'Attachment dataframe contains the following invalid names: {list(intersection)}')
-
-    def update_attachments(
-        self, feature_layer_itemid, attachment_join_field, attachment_path_field, attachments_df, layer_number=0
-    ):
-        """Update a feature layer's attachments based on info from a dataframe of desired attachment file names
-
-        Depends on a dataframe populated with a join key for the live data and the downloaded or locally-available
-        attachments. If the name of the "new" attachment is the same as an existing attachment for that feature, it is
-        not updated. If it is different or there isn't an existing attachment, the "new" attachment is attached to that
-        feature.
-
-        Args:
-            feature_layer_itemid (str): The AGOL Item ID of the feature layer to update
-            attachment_join_field (str): The field containing the join key between the attachments dataframe and the
-                                         live data
-            attachment_path_field (str): The field containing the desired attachment file path
-            attachments_df (pd.DataFrame): A dataframe of desired attachments, including a join key and the local path
-                                           to the attachment
-            layer_number (int, optional): The layer within the Item ID to update. Defaults to 0.
-
-        Returns:
-            (int, int): Tuple of counts of successful overwrites and adds.
-        """
-
-        self._class_logger.info('Updating attachments...')
-        #: These names are present in the live attachment data downloaded from AGOL. Because we merge the dataframes
-        #: later, we need to make sure they're not the same. There may be better ways of handling this that allows the
-        #: client names to be preserved, but for now force them to fix this.
-        self._check_attachment_dataframe_for_invalid_column_names(
-            attachments_df, invalid_names=['OBJECTID', 'PARENTOBJECTID', 'NAME', 'ID']
-        )
-        self._class_logger.debug('Using layer %s from item ID %s', layer_number, feature_layer_itemid)
-        self.feature_layer = self.gis.content.get(feature_layer_itemid).layers[layer_number]
-        live_features_as_df = pd.DataFrame.spatial.from_layer(self.feature_layer)
-        live_data_subset_df = self._get_live_oid_and_guid_from_join_field_values(
-            live_features_as_df, attachment_join_field, attachments_df
-        )
-        #: TODO: Make sure layer supports attachments so we don't get an arcgis error.
-        #: Check out the feature layer .properties and FeatureLayerManager.add_to_definition to check/enable?
-        attachment_eval_df = self._get_current_attachment_info_by_oid(live_data_subset_df)
-        attachment_action_df = self._create_attachment_action_df(attachment_eval_df, attachment_path_field)
-
-        overwrites_count = self._overwrite_attachments_by_oid(attachment_action_df, attachment_path_field)
-        adds_count = self._add_attachments_by_oid(attachment_action_df, attachment_path_field)
-        self._class_logger.info('%s attachments added, %s attachments overwritten', adds_count, overwrites_count)
-
-        return overwrites_count, adds_count
-
-    @staticmethod
-    def build_attachments_dataframe(input_dataframe, join_column, attachment_column, out_dir):
-        """Create an attachments dataframe by subsetting down to just the two fields and dropping any rows
-           with null/empty attachments
-
-        Args:
-            input_dataframe (pd.DataFrame): Input data containing at least the join and attachment filename columns
-            join_column (str): Unique key joining attachments to live data
-            attachment_column (str): Filename for each attachment
-            out_dir (str or Path): Output directory, will be used to build full path to attachment
-
-        Returns:
-            pd.DataFrame: Dataframe with join key, attachment name, and full attachment paths
-        """
-
-        input_dataframe[attachment_column].replace('', np.nan, inplace=True)  #: pandas doesn't see empty strings as NAs
-        attachments_dataframe = input_dataframe[[join_column, attachment_column]] \
-                                            .copy().dropna(subset=[attachment_column])
-        #: Create the full path by prepending the output directory using .apply and a lambda function
-        attachments_dataframe['full_file_path'] = attachments_dataframe[attachment_column] \
-                                                    .apply(lambda filename: str(Path(out_dir, filename)))
-
-        return attachments_dataframe
-
-
-class FeatureServiceOverwriter:
-    """Overwrites an AGOL Feature Service with data from a spatially-enabled DataFrame.
-    """
-
-    def __init__(self, gis):
-        self.gis = gis
-        self._class_logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
-
-    def _truncate_existing_data(self, featurelayer, layer_index, itemid):
-        """Remove all existing features from live dataset
-
-        Args:
-            featurelayer (arcgis.features.FeatureLayer): Live data
-            layer_index (int): Layer id within the main feature service
-            itemid (str): AGOL item ID for the main feature service
-
-        Raises:
-            RuntimeError: If the truncate fails
-
-        Returns:
-            pd.DataFrame: The feature layer's data prior to truncating
-        """
-
-        old_data = featurelayer.query(as_df=True)
-        truncate_result = utils.retry(featurelayer.manager.truncate, asynchronous=True, wait=True)
-        self._class_logger.debug(truncate_result)
-        if truncate_result['status'] != 'Completed':
-            raise RuntimeError(f'Failed to truncate existing data from layer id {layer_index} in itemid {itemid}')
-        return old_data
-
-    def _append_new_data(self, target_featurelayer, dataframe, feature_service_item_id, layer_index):
-        """Add new data to live dataset
-
-        Args:
-            target_featurelayer (arcgis.features.FeatureLayer): Live dataset; should be empty
-            dataframe (pd.DataFrame): Spatially-enabled dataframe containing new data. Column names should match live
-            data
-            feature_service_item_id (int): Layer ID within the main feature service
-            layer_index (str): AGOL item ID for the main feature service
-
-        Raises:
-            RuntimeError: If append fails; will attempt to rollback appends that worked.
-
-        Returns:
-            dict: Messages returned from append operation
-        """
-
-        geojson = dataframe.spatial.to_featureset().to_geojson
-        result, messages = utils.retry(
-            target_featurelayer.append,
-            upload_format='geojson',
-            edits=geojson,
-            #:TODO figure out a way to preserve GUIDs?
-            upsert=False,
-            return_messages=True,
-            rollback=True,
-        )
-
-        self._class_logger.debug(messages)
-        if not result:
-            raise RuntimeError(
-                f'Failed to append data to layer id {layer_index} in itemid {feature_service_item_id}. Append should'
-                ' have been rolled back.'
-            )
-
-        return messages
-
-    def _save_truncated_data(self, dataframe, directory):
-        """Save the pre-truncate dataframe to directory for safety
-
-        Args:
-            dataframe (pd.DataFrame): The data extracted from the feature layer prior to truncating
-            directory (str or Path): The directory to save the data to.
-
-        Returns:
-            Path: The full path to the output file, named with today's date.
-        """
-
-        out_path = Path(directory, f'old_data_{datetime.date.today()}.json')
-        out_path.write_text(dataframe.spatial.to_featureset().to_json)
-        return out_path
-
-    def truncate_and_load_feature_service(self, feature_service_item_id, new_dataframe, failsafe_dir, layer_index=0):
-        """Attempt to delete existing data from a feature layer and add new data from a spatially-enabled dataframe.
-
-        First attempts to truncate existing data. Then renames new data column names to conform to AGOL scheme (spaces,
-        special chars changed to '_'). Finally attempts to append new data to now-empty feature layer. If the new data
-        append fails, it attempts to re-upload the previous data from the in-memory dataframe. If this fails, it
-        attempts to failsafe by writing the old data to disk as a json file.
-
-        Args:
-            feature_service_item_id (str): AGOL item ID for feature service to truncate and load
-            new_dataframe (pd.DataFrame): Spatially-enabled dataframe containing new data. Must not contain columns
-            that do not exist in the live data. Geometry type must match existing data.
-            layer_index (int, optional): ID for the feature service layer to be truncated and loaded. Defaults to 0.
-
-        Raises:
-            RuntimeError: If new data contains a field not present in the live data
-            RuntimeError: If the truncate fails
-            RuntimeError: If append fails; will attempt to rollback appends that worked
-            RuntimeError: If rollback fails; will attempt to write old live data to disk as json
-
-        Returns:
-            int: The number of new records added after deleting all existing records.
-        """
-
-        target_featurelayer = arcgis.features.FeatureLayer.fromitem(
-            self.gis.content.get(feature_service_item_id), layer_id=layer_index
-        )
-        self._class_logger.info('Truncating existing data...')
-        old_dataframe = self._truncate_existing_data(target_featurelayer, layer_index, feature_service_item_id)
-        try:
-            cleaned_dataframe = new_dataframe.rename(columns=utils.rename_columns_for_agol(new_dataframe.columns))
-            #: temp fix until Esri fixes empty series as NaN bug
-            fixed_dataframe = utils.replace_nan_series_with_empty_strings(cleaned_dataframe)
-            utils.check_fields_match(target_featurelayer, fixed_dataframe)
-            self._class_logger.info('Loading new data...')
-
-            messages = self._append_new_data(target_featurelayer, fixed_dataframe, feature_service_item_id, layer_index)
-        except Exception:
-            try:
-                self._class_logger.info('Append failed; attempting to re-load truncated data...')
-                messages = self._append_new_data(
-                    target_featurelayer, old_dataframe, feature_service_item_id, layer_index
-                )
-                self._class_logger.info('%s features reloaded', messages['recordCount'])
-            except Exception as inner_error:
-                failsafe_path = self._save_truncated_data(old_dataframe, failsafe_dir)
-                raise RuntimeError(
-                    f'Failed to re-add truncated data after failed append; data saved to {failsafe_path}'
-                ) from inner_error
-            finally:
-                raise
-
-        return messages['recordCount']
-
-
-class ColorRampReclassifier:
-    """Updates the interval ranges on a layer's classification renderer based on the layer's current data.
-    """
-
-    def __init__(self, webmap_item, gis):
-        self.webmap_item = webmap_item
-        self.gis = gis
-        self._class_logger = logging.getLogger(__name__).getChild(self.__class__.__name__)
-
-    def _get_layer_dataframe(self, layer_name, feature_layer_number=0):
-        """Create a dataframe from layer_name in self.webmap_item
-
-        Args:
-            layer_name (str): The exact name of the layer
-            feature_layer_number (int): The number of the layer with the feature service to update. Defaults to 0.
-
-        Returns:
-            spatially-enabled data frame: The layer's data, including geometries.
-        """
-
-        self._class_logger.info('Getting dataframe from `%s` on `%s`', layer_name, self.webmap_item.title)
-        webmap_object = arcgis.mapping.WebMap(self.webmap_item)
-        layer = webmap_object.get_layer(title=layer_name)
-        feature_layer = self.gis.content.get(layer['itemId'])
-        layer_dataframe = pd.DataFrame.spatial.from_layer(feature_layer.layers[feature_layer_number])
-
-        return layer_dataframe
-
-    def _get_layer_id(self, layer_name):
-        """Get the ID number of layer_name in self.webmap_item
-
-        Args:
-            layer_name (str): The exact name of the layer
-
-        Raises:
-            ValueError: If the layer is not found in the webmap
-
-        Returns:
-            int: The index (0-based) of the the layer in the web map
-        """
-
-        data = self.webmap_item.get_data()
-        for layer_id, layer in enumerate(data['operationalLayers']):
-            if layer['title'] == layer_name:
-                self._class_logger.debug('Layer `%s` has id `%s`', layer_name, layer_id)
-                return layer_id
-
-        #: If we haven't matched the title and returned a valid id, raise an error.
-        raise ValueError(f'Could not find "{layer_name}" in {self.webmap_item.title}')
-
-    @staticmethod
-    def _calculate_new_stops(dataframe, column, stops):
-        """Calculate new stop values for an AGOL color ramp using what appears to be AGOL's method for unclassed ramps.
-
-        Args:
-            dataframe (pd.DataFrame): Data being classified
-            column (str): Column to classify
-            stops (int, optional): Number of stops to create.
-
-        Returns:
-            List: New stops cast as ints
-        """
-
-        if column not in dataframe.columns:
-            raise ValueError(f'Column `{column}` not in dataframe')
-        minval = dataframe[column].min()
-        mean = dataframe[column].mean()
-        std_dev = dataframe[column].std()
-        upper = mean + std_dev  #: AGOL's default upper value for unclassed ramps seems to be mean + 1 std dev
-
-        new_stops = np.linspace(minval, upper, stops)
-        new_stops_ints = [int(stop) for stop in new_stops]
-
-        return new_stops_ints
-
-    def _update_stop_values(self, layer_number, new_stops):
-        """Update the stop values of an (un)classified polygon renderer in an AGOL Webmap
-
-        Args:
-            layer_number (int): The index for the layer to be updated
-            new_stops (List): New values for the existing stops
-
-        Returns:
-            Bool: Success or failure of update operation
-        """
-
-        #: Get short reference to the stops dictionary from the webmap's data json
-        data = self.webmap_item.get_data()
-        renderer = data['operationalLayers'][layer_number]['layerDefinition']['drawingInfo']['renderer']
-        stops = renderer['visualVariables'][0]['stops']
-
-        #: Overwrite the value, update the webmap item
-        for stop, new_value in zip(stops, new_stops):
-            stop['value'] = new_value
-        self._class_logger.info(
-            'Updating stop values on layer number `%s` in `%s`', layer_number, self.webmap_item.title
-        )
-        result = self.webmap_item.update(item_properties={'text': json.dumps(data)})
-        self._class_logger.debug('Update result: %s', result)
-
-        return result
-
-    def update_color_ramp_values(self, layer_name, column_name, stops=5):
-        """Update the color ramp ranges for layer_name in self.webmap_item.
-
-        Does not alter colors or introduce additional stops; only overwrites the values for existing breaks.
-
-        Args:
-            layer_name (str): The exact name of the layer to be updated
-            column_name (str): The name of the attribute being displayed as an (un)classified range
-            stops (int, optional): The number of stops to calculate. Must match existing stops. Defaults to 5.
-
-        Returns:
-            Bool: Success or failure of update operation
-        """
-        layer_id = self._get_layer_id(layer_name)
-        dataframe = self._get_layer_dataframe(layer_name)
-        new_stops = self._calculate_new_stops(dataframe, column_name, stops)
-        result = self._update_stop_values(layer_id, new_stops)
-
-        return result
diff --git a/src/palletjack/utils.py b/src/palletjack/utils.py
index 9137e12..6ecfdda 100644
--- a/src/palletjack/utils.py
+++ b/src/palletjack/utils.py
@@ -1,9 +1,14 @@
+import datetime
 import logging
 import random
 import re
+import sys
+import warnings
 from math import floor
+from pathlib import Path
 from time import sleep
 
+import arcgis
 import pandas as pd
 import pygsheets
 import requests
@@ -76,26 +81,7 @@ def rename_columns_for_agol(columns):
     return rename_dict
 
 
-def replace_nan_series_with_empty_strings(dataframe):
-    """Fill all completely empty series with empty strings ('')
-
-    As of arcgis 2.0.1. to_featureset() doesn't handle completely empty series properly (it relies on str;
-    https://github.com/Esri/arcgis-python-api/issues/1281), so we convert to empty strings for the time being.
-
-    Args:
-        dataframe (pd.DataFrame): Data to clean/fix
-
-    Returns:
-        pd.DataFrame: The cleaned data
-    """
-
-    for column in dataframe:
-        if dataframe[column].isnull().all():
-            module_logger.debug('Column %s is empty; replacing np.nans with empty strings', column)
-            dataframe[column].fillna(value='', inplace=True)
-    return dataframe
-
-
+#: Unused?
 def check_fields_match(featurelayer, new_dataframe):
     """Make sure new data doesn't have any extra fields, warn if it doesn't contain all live fields
 
@@ -107,7 +93,7 @@ def check_fields_match(featurelayer, new_dataframe):
         RuntimeError: If new data contains a field not present in the live data
     """
 
-    live_fields = {field['name'] for field in featurelayer.properties['fields']}
+    live_fields = {field['name'] for field in featurelayer.properties.fields}
     new_fields = set(new_dataframe.columns)
     #: Remove SHAPE field from set (live "featurelayer.properties['fields']" does not expose the 'SHAPE' field)
     try:
@@ -126,6 +112,7 @@ def check_fields_match(featurelayer, new_dataframe):
         )
 
 
+#: Unused?
 def check_index_column_in_feature_layer(featurelayer, index_column):
     """Ensure index_column is present for any future operations
 
@@ -137,11 +124,12 @@ def check_index_column_in_feature_layer(featurelayer, index_column):
         RuntimeError: If index_column is not in featurelayer's fields
     """
 
-    featurelayer_fields = [field['name'] for field in featurelayer.properties['fields']]
+    featurelayer_fields = [field['name'] for field in featurelayer.properties.fields]
     if index_column not in featurelayer_fields:
         raise RuntimeError(f'Index column {index_column} not found in feature layer fields {featurelayer_fields}')
 
 
+#: unused?
 def rename_fields(dataframe, field_mapping):
     """Rename fields based on field_mapping
 
@@ -182,6 +170,7 @@ def build_sql_in_list(series):
         return f'({", ".join(quoted_values)})'
 
 
+#: Unused in v3, but keeping for "unique constraint" info.
 def check_field_set_to_unique(featurelayer, field_name):
     """Makes sure field_name has a "unique constraint" in AGOL, which allows it to be used for .append upserts
 
@@ -193,97 +182,132 @@ def check_field_set_to_unique(featurelayer, field_name):
         RuntimeError: If the field is not unique (or if it's indexed but not unique)
     """
 
-    fields = [field['fields'] for field in featurelayer.properties['indexes']]
+    fields = [field['fields'] for field in featurelayer.properties.indexes]
     if field_name not in fields:
         raise RuntimeError(f'{field_name} does not have a "unique constraint" set within the feature layer')
-    for field in featurelayer.properties['indexes']:
+    for field in featurelayer.properties.indexes:
         if field['fields'] == field_name:
             if not field['isUnique']:
                 raise RuntimeError(f'{field_name} does not have a "unique constraint" set within the feature layer')
 
 
-def geocode_addr(street, zone, api_key, rate_limits, **api_args):
-    """Geocode an address through the UGRC Web API geocoder
+class Geocoding:
+    """Container class for methods to geocode an address
+    """
 
-    Invalid results are returned with an x,y of 0,0, a score of 0.0, and a match address of 'No Match'
+    @staticmethod
+    def geocode_addr(street, zone, api_key, rate_limits, **api_args):
+        """Geocode an address through the UGRC Web API geocoder
 
-    Args:
-        street (str): The street address
-        zone (str): The zip code or city
-        api_key (str): API key obtained from developer.mapserv.utah.gov
-        rate_limits(Tuple <float>): A lower and upper bound in seconds for pausing between API calls. Defaults to
-        (0.015, 0.03)
-        **api_args (dict): Keyword arguments to be passed as parameters in the API GET call. The API key will be added
-        to this dict.
+        Invalid results are returned with an x,y of 0,0, a score of 0.0, and a match address of 'No Match'
 
-    Returns:
-        tuple[int]: The match's x coordinate, y coordinate, score, and match address
-    """
+        Args:
+            street (str): The street address
+            zone (str): The zip code or city
+            api_key (str): API key obtained from developer.mapserv.utah.gov
+            rate_limits(Tuple <float>): A lower and upper bound in seconds for pausing between API calls. Defaults to
+            (0.015, 0.03)
+            **api_args (dict): Keyword arguments to be passed as parameters in the API GET call. The API key will be added
+            to this dict.
 
-    sleep(random.uniform(rate_limits[0], rate_limits[1]))
-    url = f'https://api.mapserv.utah.gov/api/v1/geocode/{street}/{zone}'
-    api_args['apiKey'] = api_key
+        Returns:
+            tuple[int]: The match's x coordinate, y coordinate, score, and match address
+        """
 
-    try:
-        geocode_result_dict = retry(_geocode_api_call, url, api_args)
-    except Exception as error:
-        module_logger.error(error)
-        return (0, 0, 0., 'No API response')
+        sleep(random.uniform(rate_limits[0], rate_limits[1]))
+        url = f'https://api.mapserv.utah.gov/api/v1/geocode/{street}/{zone}'
+        api_args['apiKey'] = api_key
 
-    return (
-        geocode_result_dict['location']['x'],
-        geocode_result_dict['location']['y'],
-        geocode_result_dict['score'],
-        geocode_result_dict['matchAddress'],
-    )
+        try:
+            geocode_result_dict = retry(Geocoding._geocode_api_call, url, api_args)
+        except Exception as error:
+            module_logger.error(error)
+            return (0, 0, 0., 'No API response')
+
+        return (
+            geocode_result_dict['location']['x'],
+            geocode_result_dict['location']['y'],
+            geocode_result_dict['score'],
+            geocode_result_dict['matchAddress'],
+        )
 
+    @staticmethod
+    def _geocode_api_call(url, api_args):
+        """Makes a requests.get call to the geocoding API.
 
-def _geocode_api_call(url, api_args):
-    """Makes a requests.get call to the geocoding API.
+        Meant to be called through a retry wrapper so that the RuntimeErrors get tried again a couple times before finally
+        raising the error.
 
-    Meant to be called through a retry wrapper so that the RuntimeErrors get tried again a couple times before finally
-    raising the error.
+        Args:
+            url (str): Base url for GET request
+            api_args (dict): Dictionary of URL parameters
 
-    Args:
-        url (str): Base url for GET request
-        api_args (dict): Dictionary of URL parameters
+        Raises:
+            RuntimeError: If the server does not return response and request.get returns a falsy object.
+            RuntimeError: If the server returns a status code other than 200 or 404
 
-    Raises:
-        RuntimeError: If the server does not return response and request.get returns a falsy object.
-        RuntimeError: If the server returns a status code other than 200 or 404
+        Returns:
+            dict: The 'results' dictionary of the response json (location, score, and matchAddress)
+        """
 
-    Returns:
-        dict: The 'results' dictionary of the response json (location, score, and matchAddress)
-    """
+        response = requests.get(url, params=api_args)
 
-    response = requests.get(url, params=api_args)
-
-    #: The server times out and doesn't respond
-    if response is None:
-        module_logger.debug('GET call did not return a response')
-        raise RuntimeError('No response from GET; request timeout?')
-
-    #: The point does geocode
-    if response.status_code == 200:
-        return response.json()['result']
-
-    #: The point doesn't geocode
-    if response.status_code == 404:
-        return {
-            'location': {
-                'x': 0,
-                'y': 0
-            },
-            'score': 0.,
-            'matchAddress': 'No Match',
-        }
+        #: The server times out and doesn't respond
+        if response is None:
+            module_logger.debug('GET call did not return a response')
+            raise RuntimeError('No response from GET; request timeout?')
+
+        #: The point does geocode
+        if response.status_code == 200:
+            return response.json()['result']
+
+        #: The point doesn't geocode
+        if response.status_code == 404:
+            return {
+                'location': {
+                    'x': 0,
+                    'y': 0
+                },
+                'score': 0.,
+                'matchAddress': 'No Match',
+            }
+
+        #: If we haven't returned, raise an error to trigger _retry
+        raise RuntimeError(f'Did not receive a valid geocoding response; status code: {response.status_code}')
+
+    @staticmethod
+    def validate_api_key(api_key):
+        """Check to see if a Web API key is valid by geocoding a single, known address point
+
+        Args:
+            api_key (str): API Key
+
+        Raises:
+            RuntimeError: If there was a network or other error attempting to geocode the known point
+            ValueError: If the API responds with an invalid key message
+            UserWarning: If the API responds with some other abnormal result
+        """
 
-    #: If we haven't returned, raise an error to trigger _retry
-    raise RuntimeError(f'Did not receive a valid geocoding response; status code: {response.status_code}')
+        url = 'https://api.mapserv.utah.gov/api/v1/geocode/326 east south temple street/slc'
+
+        try:
+            response = retry(requests.get, url=url, params={'apiKey': api_key})
+        except Exception as error:
+            raise RuntimeError(
+                'Could not determine key validity; check your API key and/or network connection'
+            ) from error
+        response_json = response.json()
+        if response_json['status'] == 200:
+            return
+        if response_json['status'] == 400 and 'Invalid API key' in response_json['message']:
+            raise ValueError(f'API key validation failed: {response_json["message"]}')
+
+        warnings.warn(f'Unhandled API key validation response {response_json["status"]}: {response_json["message"]}')
 
 
 def calc_modulus_for_reporting_interval(n, split_value=500):
-    """Calculate a number that can be used as a modulus for splitting n up into 10 or 20 intervals, depending on split_value.
+    """Calculate a number that can be used as a modulus for splitting n up into 10 or 20 intervals, depending on
+    split_value.
 
     Args:
         n (int): The number to divide into intervals
@@ -302,35 +326,6 @@ def calc_modulus_for_reporting_interval(n, split_value=500):
     return floor(n / 20)
 
 
-def validate_api_key(api_key):
-    """Check to see if a Web API key is valid by geocoding a single, known address point
-
-    Args:
-        api_key (str): API Key
-
-    Returns:
-        str: One of three messages:
-                'valid': The key is valid
-                A string containing 'Invalid API key': Response from the server if the API key is invalid
-                'Could not determine key validity; check your API key and/or network connection': If there was some
-                network or response error.
-    """
-
-    url = 'https://api.mapserv.utah.gov/api/v1/geocode/326 east south temple street/slc'
-
-    try:
-        response = retry(requests.get, url=url, params={'apiKey': api_key})
-        response_json = response.json()
-        if response_json['status'] == 200:
-            return 'valid'
-        if response_json['status'] == 400 and 'Invalid API key' in response_json['message']:
-            return response_json['message']
-    except Exception as error:
-        module_logger.debug(error)
-        # return 'Could not determine key validity; check your API key and/or network connection'
-    return 'Could not determine key validity; check your API key and/or network connection'
-
-
 def authorize_pygsheets(credentials):
     """Authenticate pygsheets using either a service file or google.auth.credentials.Credentials object.
 
@@ -357,3 +352,510 @@ def authorize_pygsheets(credentials):
         return pygsheets.authorize(custom_credentials=credentials)
     except Exception as err:
         raise RuntimeError('Could not authenticate to Google API') from err
+
+
+def save_feature_layer_to_json(feature_layer, directory):
+    """Save a feature_layer to directory for safety as {layer name}_{todays date}.json
+
+    Args:
+        feature_layer (arcgis.features.FeatureLayer): The FeatureLayer object to save to disk.
+        directory (str or Path): The directory to save the data to.
+
+    Returns:
+        Path: The full path to the output file, named with the layer name and today's date.
+    """
+    module_logger.debug('Downloading existing data...')
+    dataframe = feature_layer.query().sdf
+
+    out_path = Path(directory, f'{feature_layer.properties.name}_{datetime.date.today()}.json')
+    module_logger.debug('Saving existing data to %s', out_path)
+    out_path.write_text(dataframe.spatial.to_featureset().to_json, encoding='utf-8')
+
+    return out_path
+
+
+class FieldChecker:
+    """Check the fields of a new dataframe against live data. Each method will raise errors if its checks fail.
+    """
+
+    def __init__(self, live_data_properties, new_dataframe):
+        self.live_data_properties = live_data_properties
+        self.fields_dataframe = pd.DataFrame(live_data_properties.fields)
+        self.new_dataframe = new_dataframe
+
+    def check_live_and_new_field_types_match(self, fields):
+        """Raise an error if the field types of the live and new data don't match.
+
+        Uses a dictionary mapping Esri field types to pandas dtypes. If 'SHAPE' is included in the fields, it calls
+        _check_geometry_types to verify the spatial types are compatible.
+
+        Args:
+            fields (List[str]): Fields to be updated
+
+        Raises:
+            ValueError: If the field types or spatial types are incompatible, the new data has multiple geometry types,
+            or the new data is not a valid spatially-enabled dataframe.
+            NotImplementedError: If the live data has a field that has not yet been mapped to a pandas dtype.
+        """
+
+        #: Converting dtypes to str and comparing seems to be the only way to break out into shorts and longs, singles
+        #: and doubles. Otherwise, checking subclass is probably more pythonic.
+        short_ints = ['uint8', 'uint16', 'int8', 'int16']
+        long_ints = ['int', 'uint32', 'uint64', 'int32', 'int64']
+
+        #: Leaving the commented types here for future implementation if necessary
+        esri_to_pandas_types_mapping = {
+            'esriFieldTypeInteger': ['int'] + short_ints + long_ints,
+            'esriFieldTypeSmallInteger': short_ints,
+            'esriFieldTypeDouble': ['float', 'float32', 'float64'],
+            'esriFieldTypeSingle': ['float32'],
+            'esriFieldTypeString': ['str', 'object', 'string'],
+            #  'esriFieldTypeDate': [],
+            'esriFieldTypeGeometry': ['geometry'],
+            'esriFieldTypeOID': ['int'] + short_ints + long_ints,
+            #  'esriFieldTypeBlob': [],
+            'esriFieldTypeGlobalID': ['str', 'object', 'string'],
+            #  'esriFieldTypeRaster': [],
+            #  'esriFieldTypeGUID': [],
+            #  'esriFieldTypeXML': [],
+        }
+
+        #: geometry checking gets its own function
+        if 'SHAPE' in fields:
+            self._check_geometry_types()
+            fields.remove('SHAPE')
+
+        fields_to_check = self.fields_dataframe[self.fields_dataframe['name'].isin(fields)].set_index('name')
+
+        invalid_fields = []
+        for field in fields:
+            #: check against the str.lower to catch normal dtypes (int64) and the new, pd.NA-aware dtypes (Int64)
+            new_dtype = str(self.new_dataframe[field].dtype).lower()
+            live_type = fields_to_check.loc[field, 'type']
+
+            try:
+                if new_dtype not in esri_to_pandas_types_mapping[live_type]:
+                    invalid_fields.append((field, live_type, new_dtype))
+                    # raise ValueError(f'{field} types incompatible. Live type: {live_type}. New type: {new_dtype}.')
+            except KeyError:
+                # pylint: disable-next=raise-missing-from
+                raise NotImplementedError(f'Live field "{field}" type "{live_type}" not yet mapped to a pandas dtype')
+
+        if invalid_fields:
+            raise ValueError(f'Field type incompatibilities (field, live type, new type): {invalid_fields}')
+
+    def _check_geometry_types(self):
+        """Raise an error if the live and new data geometry types are incompatible.
+
+        Args:
+            live_data_properties (dict): FeatureLayer.properties of live data
+            new_dataframe (pd.DataFrame): New data to be added/updated
+
+        Raises:
+            ValueError: If the new data is not a valid spatially-enabled dataframe, has multiple geometry types, or has
+            a geometry type that doesn't match the live data.
+        """
+
+        esri_to_sedf_geometry_mapping = {
+            'esriGeometryPoint': 'point',
+            'esriGeometryMultipoint': 'multipoint',
+            'esriGeometryPolyline': 'polyline',
+            'esriGeometryPolygon': 'polygon',
+            'esriGeometryEnvelope': 'envelope',
+        }
+
+        if 'SHAPE' not in self.new_dataframe.columns:
+            raise ValueError('New dataframe does not have a SHAPE column')
+
+        live_geometry_type = self.live_data_properties.geometryType
+        new_geometry_types = self.new_dataframe.spatial.geometry_type
+        if len(new_geometry_types) > 1:
+            raise ValueError('New dataframe has multiple geometry types')
+        if esri_to_sedf_geometry_mapping[live_geometry_type] != new_geometry_types[0].lower():
+            raise ValueError(
+                f'New dataframe geometry type "{new_geometry_types[0]}" incompatible with live geometry type "{live_geometry_type}"'
+            )
+
+    def check_for_non_null_fields(self, fields):
+        """Raise an error if the new data contains nulls in a field that the live data says is not nullable.
+
+        If this error occurs, the client should use pandas fillna() method to replace NaNs/Nones with empty strings or
+        appropriate nodata values.
+
+        Args:
+            fields (List[str]): Fields to check
+
+        Raises:
+            ValueError: If the new data contains nulls in a field that the live data says is not nullable and doesn't
+            have a default value.
+        """
+
+        columns_with_nulls = self.new_dataframe.columns[self.new_dataframe.isna().any()].tolist()
+        # fields_dataframe = pd.DataFrame(self.live_data_properties['fields'])
+        non_nullable_live_columns = self.fields_dataframe[
+            ~(self.fields_dataframe['nullable']) &
+            ~(self.fields_dataframe['defaultValue'].astype(bool))]['name'].tolist()
+
+        columns_to_check = [column for column in columns_with_nulls if column in fields]
+
+        #: If none of the columns have nulls, we don't need to check further
+        if not columns_to_check:
+            return
+
+        problem_fields = []
+        for column in columns_to_check:
+            if column in non_nullable_live_columns:
+                problem_fields.append(column)
+
+        if problem_fields:
+            raise ValueError(
+                f'The following fields cannot have null values in the live data but one or more nulls exist in the new data: {", ".join(problem_fields)}'
+            )
+
+    def check_field_length(self, fields):
+        """Raise an error if a new data string value is longer than allowed in the live data.
+
+        Args:
+            fields (List[str]): Fields to check
+
+        Raises:
+            ValueError: If the string fields in the new data contain a value longer than the corresponding field in the
+            live data allows.
+        """
+
+        if 'length' not in self.fields_dataframe.columns:
+            module_logger.debug('No fields with length property')
+            return
+
+        length_limited_fields = self.fields_dataframe[
+            (self.fields_dataframe['type'].isin(['esriFieldTypeString', 'esriFieldTypeGlobalID'])) &
+            (self.fields_dataframe['length'].astype(bool))]
+
+        columns_to_check = length_limited_fields[length_limited_fields['name'].isin(fields)]
+
+        for field, live_max_length in columns_to_check[['name', 'length']].to_records(index=False):
+            new_data_lengths = self.new_dataframe[field].str.len()
+            new_max_length = new_data_lengths.max()
+            if new_max_length > live_max_length:
+                raise ValueError(
+                    f'Row {new_data_lengths.argmax()}, column {field} in new data exceeds the live data max length of {live_max_length}'
+                )
+
+    def check_fields_present(self, fields, add_oid):
+        """Raise an error if the fields to be operated on aren't present in either the live or new data.
+
+        Args:
+            fields (List[str]): The fields to be operated on.
+            add_oid (bool): Add OBJECTID to fields if its not already present (for operations that are dependent on
+                            OBJECTID, such as upsert)
+
+        Raises:
+            RuntimeError: If any of fields are not in live or new data.
+        """
+
+        live_fields = set(self.fields_dataframe['name'])
+        new_fields = set(self.new_dataframe.columns)
+        working_fields = set(fields)
+        if add_oid:
+            working_fields.add('OBJECTID')
+
+        live_dif = working_fields - live_fields
+        new_dif = working_fields - new_fields
+
+        error_message = []
+        if live_dif:
+            error_message.append(f'Fields missing in live data: {", ".join(live_dif)}')
+        if new_dif:
+            error_message.append(f'Fields missing in new data: {", ".join(new_dif)}')
+
+        if error_message:
+            raise RuntimeError('. '.join(error_message))
+
+    def check_for_empty_float_fields(self, fields):
+        """Raise an error if any of the float fields are completely empty.
+
+        As of arcgis 2.0.1. to_featureset() doesn't handle completely empty series properly (it relies on str;
+        https://github.com/Esri/arcgis-python-api/issues/1281), so we give the user a warning if they're passing an
+        empty field.
+
+        Args:
+            fields (list[str]): The fields to be operated on.
+
+        Raises:
+            ValueError: If any of the columns only contain nulls
+        """
+
+        float_types = ['float', 'float32', 'float64']
+        float_fields = [field for field in fields if str(self.new_dataframe[field].dtype) in float_types]
+        empty_fields = []
+        for field in float_fields:
+            if self.new_dataframe[field].isnull().all():
+                empty_fields.append(field)
+
+        if empty_fields:
+            raise ValueError(
+                f'The following float/double column(s) are completely empty: {empty_fields} (suggestion: insert at least one bogus value)'
+            )
+
+    def check_srs_match(self):
+        """Raise an error if the new and live spatial reference systems don't match.
+
+        Raises:
+            ValueError: If the new or existing SRS values can't be cast to an int (please log an issue if this occurs)
+            ValueError: If the new and live SRS values don't match.
+        """
+
+        try:
+            live_srs = int(self.live_data_properties.extent.spatialReference.latestWkid)
+            new_srs = int(self.new_dataframe.spatial.sr.wkid)
+        except ValueError as error:
+            raise ValueError('Could not cast either new or existing SRS to int') from error
+        if live_srs != new_srs:
+            raise ValueError(f'New dataframe SRS {new_srs} does not match live SRS {live_srs}')
+
+
+def get_null_geometries(feature_layer_properties):
+    """Generate placeholder geometries near 0, 0 with type based on provided feature layer properties dictionary.
+
+    Args:
+        feature_layer_properties (dict): .properties from a feature layer item, contains 'geometryType' key
+
+    Raises:
+        NotImplementedError: If we get a geometryType we haven't implemented a null-geometry generator for
+
+    Returns:
+        arcgis.geometry.Geometry: A geometry object of the corresponding type centered around null island.
+    """
+
+    # esri_to_sedf_geometry_mapping = {
+    #     'esriGeometryPoint': 'point',
+    #     'esriGeometryMultipoint': 'multipoint',
+    #     'esriGeometryPolyline': 'polyline',
+    #     'esriGeometryPolygon': 'polygon',
+    #     'esriGeometryEnvelope': 'envelope',
+    # }
+
+    live_geometry_type = feature_layer_properties.geometryType
+    live_srs = feature_layer_properties.extent.spatialReference.latestWkid
+
+    if live_geometry_type == 'esriGeometryPoint':
+        return arcgis.geometry.Point({'x': 0, 'y': 0, 'spatialReference': {'wkid': live_srs}}).JSON
+
+    if live_geometry_type == 'esriGeometryPolyline':
+        return arcgis.geometry.Polyline({
+            'paths': [[[0, 0], [.1, .1], [.2, .2]]],
+            'spatialReference': {
+                'wkid': live_srs
+            }
+        }).JSON
+
+    if live_geometry_type == 'esriGeometryPolygon':
+        return arcgis.geometry.Polygon({
+            'rings': [[[0, .1], [.1, .1], [.1, 0], [0, 0]]],
+            'spatialReference': {
+                'wkid': live_srs
+            }
+        }).JSON
+
+    raise NotImplementedError(f'Null value generator for live geometry type {live_geometry_type} not yet implemented')
+
+
+class DeleteUtils:
+    """Container class for verifying Object IDs used for delete operations
+    """
+
+    @staticmethod
+    def check_delete_oids_are_ints(oid_list):
+        """Raise an error if a list of strings can't be parsed as ints
+
+        Args:
+            oid_list (list[int]): List of Object IDs to delete
+
+        Raises:
+            TypeError: If any of the items in oid_list can't be cast to ints
+
+        Returns:
+            list[int]: oid_list converted to ints
+        """
+
+        numeric_oids = []
+        bad_oids = []
+        for oid in oid_list:
+            try:
+                numeric_oids.append(int(oid))
+            except ValueError:
+                bad_oids.append(oid)
+        if bad_oids:
+            raise TypeError(f'Couldn\'t convert OBJECTID(s) `{bad_oids}` to integer')
+        return numeric_oids
+
+    @staticmethod
+    def check_for_empty_oid_list(oid_list, numeric_oids):
+        """Raise an error if the parsed Object ID list is empty
+
+        Args:
+            oid_list (list[int]): The original list of Object IDs to delete
+            numeric_oids (list[int]): The cast-to-int Object IDs
+
+        Raises:
+            ValueError: If numeric_oids is empty
+        """
+
+        if not numeric_oids:
+            raise ValueError(f'No OBJECTIDs found in {oid_list}')
+
+    @staticmethod
+    def check_delete_oids_are_in_live_data(oid_string, numeric_oids, feature_layer):
+        """Warn if a delete Object ID doesn't exist in the live data, return number missing
+
+        Args:
+            oid_string (str): Comma-separated string of delete Object IDs
+            numeric_oids (list[int]): The parsed and cast-to-int Object IDs
+            feature_layer (arcgis.features.FeatureLayer): Live FeatureLayer item
+
+        Raises:
+            UserWarning: If any of the Object IDs in numeric_oids don't exist in the live data.
+
+        Returns:
+            int: Number of Object IDs missing from live data
+        """
+
+        query_results = feature_layer.query(object_ids=oid_string, return_ids_only=True)
+        query_oids = query_results['objectIds']
+        oids_not_in_layer = set(numeric_oids) - set(query_oids)
+
+        if oids_not_in_layer:
+            warnings.warn(f'OBJECTIDs {oids_not_in_layer} were not found in the live data')
+
+        return len(oids_not_in_layer)
+
+
+class Chunking:
+    """Container class for dividing a dataframe into chunks to satisfy size requirements for append operation
+    """
+
+    @staticmethod
+    def _ceildiv(num, denom):
+        """Perform ceiling division: 5/4 = 2
+
+        Args:
+            num (int or float): Numerator
+            denom (int or float): Denominator
+
+        Returns:
+            int: Ceiling divisor
+        """
+
+        return -(num // -denom)
+
+    @staticmethod
+    def _chunk_dataframe(dataframe, chunk_size):
+        """Divide up a dataframe into a list of dataframes of chunk_size rows
+
+        The DataFrames are returned in a list. Elements [:-1] are as large as possible for the number of chunks needed,
+        while the last gets however many rows of the dataframe are left over. eg, a 10-row dataframe broken into 3
+        chunks would result in dataframes with 3, 3, and 1 rows.
+
+        Args:
+            dataframe (pd.DataFrame): Input DataFrame
+            chunk_size (int): The max number of rows for each sub dataframe
+
+        Returns:
+            list[pd.DataFrame]: A list of dataframes with at most chunk_size rows per dataframe
+        """
+
+        df_length = len(dataframe)
+
+        starts = range(0, df_length, chunk_size)
+        ends = [start + chunk_size if start + chunk_size < df_length else df_length for start in starts]
+        list_of_dataframes = [dataframe.iloc[start:end] for start, end in zip(starts, ends)]
+
+        return list_of_dataframes
+
+    @staticmethod
+    def build_upload_json(dataframe, feature_layer_fields, max_bytes=100_000_000):
+        """Create list of geojson strings of spatially-enabled DataFrame, divided into chunks if it exceeds max_bytes
+
+        Recursively chunks dataframe to ensure no one chunk is larger than max_bytes. Converts all empty strings in
+        nullable numeric fields in feature sets created from individual chunks to None prior to converting to geojson to
+        ensure the field stays numeric.
+
+        Args:
+            dataframe (pd.DataFrame.spatial): Spatially-enabled dataframe to be converted to geojson
+            max_bytes (int, optional): Maximum size in bytes any one geojson string can be. Defaults to 100000000 (AGOL
+            text uploads are limited to 100 MB?)
+
+        Returns:
+            list[str]: A list of the dataframe chunks converted to geojson
+        """
+
+        geojson_size = sys.getsizeof(dataframe.spatial.to_featureset().to_geojson.encode('utf-16'))
+        module_logger.debug('Initial file size: %s', geojson_size)
+
+        chunked_dataframes = Chunking._recursive_dataframe_chunking(dataframe, max_bytes)
+
+        chunked_geojsons = [
+            fix_numeric_empty_strings(chunk.spatial.to_featureset(), feature_layer_fields).to_geojson
+            for chunk in chunked_dataframes
+        ]
+
+        return chunked_geojsons
+
+    @staticmethod
+    def _recursive_dataframe_chunking(dataframe, max_bytes):
+        """Break a dataframe into chunks such that their utf-16 encoded geojson sizes don't exceed max_bytes
+
+        Divides the dataframe into chunks based on the geojson representation's utf-16-encoded size by calculating the
+        number of chunks of size > max_bytes needed for the entire file size. It uses this number of chunks to chunk the
+        dataframe based on rows. Because there can be variability in geojson file size due to attribute lengths
+        (especially line and polygon geometry sizes), it uses recursion to again chunk each smaller dataframe if needed.
+
+        The chunks should (but not definitely proven to) maintain the sequential order of the features of the original
+        dataframe. Suppose an initial 10 rows gives us chunks for rows [1, 2, 3], [4, 5, 6], [7, 8, 9], [10]. However,
+        the second chunk [4, 5, 6] turns out to be too large, so it gets divided into [4, 5] and [6]. The resulting
+        list of chunks should be [1, 2, 3], [4, 5], [6], [7, 8, 9], [10]
+
+        Args:
+            dataframe (pd.DataFrame.spatial): A spatially-enabled dataframe to divide
+            max_bytes (int): The max utf-16 encoded geojson size for any one chunk
+        """
+
+        #: Calculate number of chunks needed and the guesstimate max number of rows to achieve that size
+        geojson_size = sys.getsizeof(dataframe.spatial.to_featureset().to_geojson.encode('utf-16'))
+        chunks_needed = Chunking._ceildiv(geojson_size, max_bytes)
+        max_rows = Chunking._ceildiv(len(dataframe), chunks_needed)
+
+        #: Chunk the dataframe and then check if the resulting chunks are now within the proper size, calling again on
+        #: the offending chunks if not
+        list_of_dataframes = Chunking._chunk_dataframe(dataframe, max_rows)
+        return_dataframes = []  #: Holds result of valid and recursive chunks
+        for chunk_dataframe in list_of_dataframes:
+            chunk_geojson_size = sys.getsizeof(chunk_dataframe.spatial.to_featureset().to_geojson.encode('utf-16'))
+            if chunk_geojson_size > max_bytes:
+                return_dataframes.extend(Chunking._recursive_dataframe_chunking(chunk_dataframe, max_bytes))
+            else:
+                return_dataframes.append(chunk_dataframe)
+
+        return return_dataframes
+
+
+def fix_numeric_empty_strings(feature_set, feature_layer_fields):
+    """Replace empty strings with None for numeric fields that allow nulls
+    Args:
+        feature_set (arcgis.features.FeatureSet): Feature set to clean
+        fields (Dict): fields from feature layer
+    """
+
+    fields_to_fix = {
+        field['name']
+        for field in feature_layer_fields
+        if field['type'] in ['esriFieldTypeDouble', 'esriFieldTypeInteger'] and field['nullable']
+    }
+    fields_to_fix -= {'Shape__Length', 'Shape__Area'}
+
+    for feature in feature_set.features:
+        for field_name in fields_to_fix:
+            if feature.attributes[field_name] == '':
+                feature.attributes[field_name] = None
+
+    return feature_set


Test patch:
diff --git a/tests/mock_arcpy.py b/tests/mock_arcpy.py
index 1460af1..d4456c0 100644
--- a/tests/mock_arcpy.py
+++ b/tests/mock_arcpy.py
@@ -1,8 +1,6 @@
 import sys
-import types
 from unittest.mock import Mock
 
 module_name = 'arcpy'
-arcpy = types.ModuleType(module_name)
+arcpy = Mock(name=module_name)
 sys.modules[module_name] = arcpy
-arcpy.da = Mock(name=module_name + '.da')
diff --git a/tests/test_updaters.py b/tests/old_test_load.py
similarity index 76%
rename from tests/test_updaters.py
rename to tests/old_test_load.py
index 37c2f56..01448b4 100644
--- a/tests/test_updaters.py
+++ b/tests/old_test_load.py
@@ -6,16 +6,16 @@
 from pathlib import Path
 
 import arcgis
+import mock_arcpy
 import numpy as np
 import pandas as pd
 import pandas.testing as tm
 import pytest
 import requests
 from arcgis.features import GeoAccessor, GeoSeriesAccessor
-from mock_arcpy import arcpy
 from pandas.api.types import CategoricalDtype
 
-import palletjack
+from palletjack import load
 
 
 @pytest.fixture
@@ -58,7 +58,7 @@ def mocked_import(name, *args, **kwargs):
     monkeypatch.setattr(builtins, '__import__', mocked_import)
 
 
-class TestFeatureServiceInlineUpdater:
+class TestFeatureServiceUpdater:
 
     def test_update_existing_features_in_feature_service_with_arcpy(self, mocker):
         #: We create a mock that will be returned by UpdateCursor's mock's __enter__, thus becoming our context manager.
@@ -75,7 +75,7 @@ def test_update_existing_features_in_feature_service_with_arcpy(self, mocker):
         fsupdater_mock = mocker.Mock()
         fsupdater_mock.new_data_as_dict = {'12345': {'Count': '57', 'Amount': 100.00, 'Date': '1/1/2022'}}
 
-        palletjack.FeatureServiceInlineUpdater.update_existing_features_in_feature_service_with_arcpy(
+        load.FeatureServiceUpdater.update_existing_features_in_feature_service_with_arcpy(
             fsupdater_mock, 'foo', ['ZipCode', 'Count', 'Amount', 'Date']
         )
 
@@ -86,7 +86,7 @@ def test_update_existing_features_in_feature_service_with_arcpy(self, mocker):
     def test_update_existing_features_in_feature_service_with_arcpy_reports_error_on_import_failure(self, mocker):
 
         with pytest.raises(ImportError, match='Failure importing arcpy. ArcGIS Pro must be installed.'):
-            palletjack.FeatureServiceInlineUpdater.update_existing_features_in_feature_service_with_arcpy(
+            load.FeatureServiceUpdater.update_existing_features_in_feature_service_with_arcpy(
                 mocker.Mock(), 'foo', ['ZipCode', 'Count', 'Amount', 'Date']
             )
 
@@ -95,7 +95,7 @@ def test_clean_dataframe_columns_renames_and_drops_columns(self, mocker):
         fields = ['foo', 'bar']
         dataframe = pd.DataFrame(columns=['foo_x', 'bar_x', 'baz', 'foo_y', 'bar_y'])
 
-        renamed = palletjack.FeatureServiceInlineUpdater._clean_dataframe_columns(class_mock, dataframe, fields)
+        renamed = load.FeatureServiceUpdater._clean_dataframe_columns(class_mock, dataframe, fields)
 
         assert list(renamed.columns) == ['baz', 'foo', 'bar']
 
@@ -104,7 +104,7 @@ def test_clean_dataframe_columns_handles_field_not_found_in_dataframe(self, mock
         fields = ['foo', 'bar', 'buz']
         dataframe = pd.DataFrame(columns=['foo_x', 'bar_x', 'baz', 'foo_y', 'bar_y'])
 
-        renamed = palletjack.FeatureServiceInlineUpdater._clean_dataframe_columns(class_mock, dataframe, fields)
+        renamed = load.FeatureServiceUpdater._clean_dataframe_columns(class_mock, dataframe, fields)
 
         assert list(renamed.columns) == ['baz', 'foo', 'bar']
 
@@ -113,53 +113,58 @@ def test_clean_dataframe_columns_deletes_merge_field(self, mocker):
         fields = ['foo', 'bar', 'buz']
         dataframe = pd.DataFrame(columns=['foo_x', 'bar_x', 'baz', 'foo_y', 'bar_y', '_merge'])
 
-        renamed = palletjack.FeatureServiceInlineUpdater._clean_dataframe_columns(class_mock, dataframe, fields)
+        renamed = load.FeatureServiceUpdater._clean_dataframe_columns(class_mock, dataframe, fields)
 
         assert list(renamed.columns) == ['baz', 'foo', 'bar']
 
     def test_get_common_rows_joins_properly_all_rows_in_both(self, mocker):
         class_mock = mocker.Mock()
         class_mock.index_column = 'key'
-        class_mock.new_dataframe = pd.DataFrame({'col1': [10, 20, 30], 'col2': [40, 50, 60], 'key': ['a', 'b', 'c']})
-        live_dataframe = pd.DataFrame({'col1': [1, 2, 3], 'col2': [4, 5, 6], 'key': ['a', 'b', 'c']})
+        class_mock.new_dataframe = pd.DataFrame({
+            'col1': [10, 20, 30],
+            'col2': [40, 50, 60],
+            'key': ['a', 'b', 'c'],
+        })
+        live_dataframe = pd.DataFrame({
+            'col1': [1, 2, 3],
+            'col2': [4, 5, 6],
+            'key': ['a', 'b', 'c'],
+        })
 
-        joined = palletjack.FeatureServiceInlineUpdater._get_common_rows(class_mock, live_dataframe)
+        joined = load.FeatureServiceUpdater._get_common_rows(class_mock, live_dataframe)
 
-        merge_type = CategoricalDtype(categories=['left_only', 'right_only', 'both'], ordered=False)
         expected = pd.DataFrame({
-            'col1_x': [1, 2, 3],
-            'col2_x': [4, 5, 6],
+            'col1': [10, 20, 30],
+            'col2': [40, 50, 60],
             'key': ['a', 'b', 'c'],
-            'col1_y': [10, 20, 30],
-            'col2_y': [40, 50, 60],
-            '_merge': ['both', 'both', 'both']
         })
-        expected['_merge'] = expected['_merge'].astype(merge_type)
 
-        pd.testing.assert_frame_equal(joined, expected)
+        pd.testing.assert_frame_equal(joined, expected, check_like=True)
 
     def test_get_common_rows_subsets_properly_new_has_fewer_rows(self, mocker):
 
         class_mock = mocker.Mock()
         class_mock.index_column = 'key'
-        class_mock.new_dataframe = pd.DataFrame({'col1': [20, 30], 'col2': [50, 60], 'key': ['b', 'c']})
-        live_dataframe = pd.DataFrame({'col1': [1, 2, 3], 'col2': [4, 5, 6], 'key': ['a', 'b', 'c']})
+        class_mock.new_dataframe = pd.DataFrame({
+            'col1': [20, 30],
+            'col2': [50, 60],
+            'key': ['b', 'c'],
+        })
+        live_dataframe = pd.DataFrame({
+            'col1': [1, 2, 3],
+            'col2': [4, 5, 6],
+            'key': ['a', 'b', 'c'],
+        })
 
-        joined = palletjack.FeatureServiceInlineUpdater._get_common_rows(class_mock, live_dataframe)
+        joined = load.FeatureServiceUpdater._get_common_rows(class_mock, live_dataframe)
 
-        merge_type = CategoricalDtype(categories=['left_only', 'right_only', 'both'], ordered=False)
         expected = pd.DataFrame({
-            'col1_x': [2, 3],
-            'col2_x': [5, 6],
+            'col1': [20, 30],
+            'col2': [50, 60],
             'key': ['b', 'c'],
-            'col1_y': [20, 30],
-            'col2_y': [50, 60],
-            '_merge': ['both', 'both']
-        },
-                                index=[1, 2])
-        expected['_merge'] = expected['_merge'].astype(merge_type)
+        })
 
-        pd.testing.assert_frame_equal(joined, expected, check_dtype=False)
+        pd.testing.assert_frame_equal(joined, expected, check_like=True)
 
     def test_get_common_rows_logs_warning_for_rows_not_in_existing_dataset(self, mocker, caplog):
 
@@ -171,27 +176,51 @@ def test_get_common_rows_logs_warning_for_rows_not_in_existing_dataset(self, moc
             'key': ['a', 'b', 'c', 'd']
         })
         class_mock._class_logger = logging.getLogger('root')
-        live_dataframe = pd.DataFrame({'col1': [1, 2, 3], 'col2': [4, 5, 6], 'key': ['a', 'b', 'c']})
+        live_dataframe = pd.DataFrame({
+            'col1': [1, 2, 3],
+            'col2': [4, 5, 6],
+            'key': ['a', 'b', 'c'],
+        })
 
-        joined = palletjack.FeatureServiceInlineUpdater._get_common_rows(class_mock, live_dataframe)
+        joined = load.FeatureServiceUpdater._get_common_rows(class_mock, live_dataframe)
 
-        merge_type = CategoricalDtype(categories=['left_only', 'right_only', 'both'], ordered=False)
         expected = pd.DataFrame({
-            'col1_x': [1, 2, 3],
-            'col2_x': [4, 5, 6],
             'key': ['a', 'b', 'c'],
-            'col1_y': [10, 20, 30],
-            'col2_y': [40, 50, 60],
-            '_merge': ['both', 'both', 'both']
-        },
-                                index=[0, 1, 2])
-        expected['_merge'] = expected['_merge'].astype(merge_type)
+            'col1': [10, 20, 30],
+            'col2': [40, 50, 60],
+        },)
 
-        pd.testing.assert_frame_equal(joined, expected, check_dtype=False)
+        pd.testing.assert_frame_equal(joined, expected, check_like=True)
         assert 'The following keys from the new data were not found in the existing dataset: [\'d\']' in caplog.text
 
+    def test_get_common_rows_handles_ints_in_existing_to_float_for_rows_not_in_existing_dataset(self, mocker, caplog):
+
+        class_mock = mocker.Mock()
+        class_mock.index_column = 'key'
+        class_mock.new_dataframe = pd.DataFrame({
+            'col1': [10, 20, 30, 80],
+            'col2': [40, 50, 60, 70],
+            'key': ['a', 'b', 'c', 'd']
+        })
+        class_mock._class_logger = logging.getLogger('root')
+        live_dataframe = pd.DataFrame({
+            'col1': [1, 2, 3],
+            'col2': [4, 5, 6],
+            'key': ['a', 'b', 'c'],
+        })
+
+        joined = load.FeatureServiceUpdater._get_common_rows(class_mock, live_dataframe)
+
+        expected = pd.DataFrame({
+            'key': ['a', 'b', 'c'],
+            'col1': [10, 20, 30],
+            'col2': [40, 50, 60],
+        },)
+
+        pd.testing.assert_frame_equal(joined, expected, check_like=True)
+
 
-class TestFeatureServiceInlineUpdaterResultParsing:
+class TestFeatureServiceUpdaterViaEditResultParsing:
 
     def test_parse_results_returns_correct_number_of_updated_rows(self, mocker, combined_values):
         class_mock = mocker.Mock()
@@ -215,7 +244,7 @@ def test_parse_results_returns_correct_number_of_updated_rows(self, mocker, comb
             'data': ['foo', 'bar'],
         })
 
-        rows_updated = palletjack.FeatureServiceInlineUpdater._parse_results(class_mock, results_dict, live_dataframe)
+        rows_updated = load.FeatureServiceUpdater._parse_results(class_mock, results_dict, live_dataframe)
 
         assert rows_updated == 2
 
@@ -241,7 +270,7 @@ def test_parse_results_returns_0_if_both_successful_and_failed_result(self, mock
             'data': ['foo', 'bar'],
         })
 
-        rows_updated = palletjack.FeatureServiceInlineUpdater._parse_results(class_mock, results_dict, live_dataframe)
+        rows_updated = load.FeatureServiceUpdater._parse_results(class_mock, results_dict, live_dataframe)
 
         assert rows_updated == 0
 
@@ -291,9 +320,7 @@ def test_parse_results_logs_success_info(self, mocker, caplog, combined_values):
         })
 
         with caplog.at_level(logging.INFO):
-            rows_updated = palletjack.FeatureServiceInlineUpdater._parse_results(
-                class_mock, results_dict, live_dataframe
-            )
+            rows_updated = load.FeatureServiceUpdater._parse_results(class_mock, results_dict, live_dataframe)
 
             assert '2 rows successfully updated' in caplog.text
             assert "Existing data: {'objectId': 1, 'data': 'foo'}" not in caplog.text
@@ -347,9 +374,7 @@ def test_parse_results_logs_success_debug(self, mocker, caplog, combined_values)
         })
 
         with caplog.at_level(logging.DEBUG):
-            rows_updated = palletjack.FeatureServiceInlineUpdater._parse_results(
-                class_mock, results_dict, live_dataframe
-            )
+            rows_updated = load.FeatureServiceUpdater._parse_results(class_mock, results_dict, live_dataframe)
 
             assert '2 rows successfully updated' in caplog.text
             assert "Existing data: {'objectId': 1, 'data': 'foo'}" in caplog.text
@@ -404,9 +429,7 @@ def test_parse_results_logs_failures_at_warning(self, mocker, caplog):
         })
 
         with caplog.at_level(logging.WARNING):
-            rows_updated = palletjack.FeatureServiceInlineUpdater._parse_results(
-                class_mock, results_dict, live_dataframe
-            )
+            rows_updated = load.FeatureServiceUpdater._parse_results(class_mock, results_dict, live_dataframe)
 
             assert caplog.records[
                 0
@@ -465,9 +488,7 @@ def test_parse_results_doesnt_log_any_success_on_all_failed(self, mocker, caplog
         })
 
         with caplog.at_level(logging.INFO):
-            rows_updated = palletjack.FeatureServiceInlineUpdater._parse_results(
-                class_mock, results_dict, live_dataframe
-            )
+            rows_updated = load.FeatureServiceUpdater._parse_results(class_mock, results_dict, live_dataframe)
 
             assert 'rows successfully updated' not in caplog.text
 
@@ -518,9 +539,7 @@ def test_parse_results_logs_successes_before_failure(self, mocker, caplog):
         })
 
         with caplog.at_level(logging.DEBUG):
-            rows_updated = palletjack.FeatureServiceInlineUpdater._parse_results(
-                class_mock, results_dict, live_dataframe
-            )
+            rows_updated = load.FeatureServiceUpdater._parse_results(class_mock, results_dict, live_dataframe)
             assert caplog.records[0].message == '1 rows successfully updated:'
             assert caplog.records[0].levelname == 'INFO'
             assert caplog.records[1].message == "Existing data: {'objectId': 1, 'data': 'foo'}"
@@ -549,9 +568,7 @@ def test_parse_results_returns_0_when_results_are_empty(self, mocker, caplog):
         })
 
         with caplog.at_level(logging.INFO):
-            rows_updated = palletjack.FeatureServiceInlineUpdater._parse_results(
-                class_mock, results_dict, live_dataframe
-            )
+            rows_updated = load.FeatureServiceUpdater._parse_results(class_mock, results_dict, live_dataframe)
 
             assert rows_updated == 0
 
@@ -569,9 +586,7 @@ def test_parse_results_logs_appropriately_when_results_are_empty(self, mocker, c
         })
 
         with caplog.at_level(logging.INFO):
-            rows_updated = palletjack.FeatureServiceInlineUpdater._parse_results(
-                class_mock, results_dict, live_dataframe
-            )
+            rows_updated = load.FeatureServiceUpdater._parse_results(class_mock, results_dict, live_dataframe)
 
             assert 'No update results returned; no updates attempted' in caplog.text
             assert 'rows successfully updated' not in caplog.text
@@ -612,7 +627,7 @@ def test_get_old_and_new_values_with_correct_data(self, mocker):
         }
         oids = [10, 2]
 
-        combined_data = palletjack.FeatureServiceInlineUpdater._get_old_and_new_values(class_mock, live_dict, oids)
+        combined_data = load.FeatureServiceUpdater._get_old_and_new_values(class_mock, live_dict, oids)
 
         assert combined_data == {
             2: {
@@ -684,7 +699,7 @@ def test_get_old_and_new_values_only_includes_existing_data_that_match_passed_ob
         }
         oids = [10, 2]
 
-        combined_data = palletjack.FeatureServiceInlineUpdater._get_old_and_new_values(class_mock, live_dict, oids)
+        combined_data = load.FeatureServiceUpdater._get_old_and_new_values(class_mock, live_dict, oids)
 
         assert combined_data == {
             2: {
@@ -755,7 +770,7 @@ def test_get_old_and_new_values_only_returns_new_data_that_match_passed_objectid
         }
         oids = [10, 2]
 
-        combined_data = palletjack.FeatureServiceInlineUpdater._get_old_and_new_values(class_mock, live_dict, oids)
+        combined_data = load.FeatureServiceUpdater._get_old_and_new_values(class_mock, live_dict, oids)
 
         assert combined_data == {
             2: {
@@ -787,31 +802,31 @@ def test_get_old_and_new_values_only_returns_new_data_that_match_passed_objectid
         }
 
 
-class TestFeatureServiceInlineUpdaterFieldValidation:
+class TestFeatureServiceUpdaterFieldValidation:
 
     def test_validate_working_fields_in_live_and_new_dataframes_doesnt_raise_when_matching(self, mocker):
-        live_df = pd.DataFrame(columns=['field1', 'field2', 'field3'])
-        new_df = pd.DataFrame(columns=['field1', 'field2', 'field3'])
+        live_df = pd.DataFrame(columns=['field1', 'field2', 'field3', 'join'])
+        new_df = pd.DataFrame(columns=['field1', 'field2', 'field3', 'join'])
         fields = ['field1', 'field2', 'field3']
 
         updater_mock = mocker.Mock()
         updater_mock.new_dataframe = new_df
+        updater_mock.index_column = 'join'
 
         #: This shouldn't raise an exception and so the test should pass
-        palletjack.FeatureServiceInlineUpdater._validate_working_fields_in_live_and_new_dataframes(
-            updater_mock, live_df, fields
-        )
+        load.FeatureServiceUpdater._validate_working_fields_in_live_and_new_dataframes(updater_mock, live_df, fields)
 
     def test_validate_working_fields_in_live_and_new_dataframes_raises_not_in_live(self, mocker):
-        live_df = pd.DataFrame(columns=['field1', 'field2'])
-        new_df = pd.DataFrame(columns=['field1', 'field2', 'field3'])
+        live_df = pd.DataFrame(columns=['field1', 'field2', 'join'])
+        new_df = pd.DataFrame(columns=['field1', 'field2', 'field3', 'join'])
         fields = ['field1', 'field2', 'field3']
 
         updater_mock = mocker.Mock()
         updater_mock.new_dataframe = new_df
+        updater_mock.index_column = 'join'
 
         with pytest.raises(RuntimeError) as exc_info:
-            palletjack.FeatureServiceInlineUpdater._validate_working_fields_in_live_and_new_dataframes(
+            load.FeatureServiceUpdater._validate_working_fields_in_live_and_new_dataframes(
                 updater_mock, live_df, fields
             )
         assert exc_info.value.args[
@@ -819,15 +834,16 @@ def test_validate_working_fields_in_live_and_new_dataframes_raises_not_in_live(s
         ] == 'Field mismatch between defined fields and either new or live data.\nFields not in live data: {\'field3\'}\nFields not in new data: set()'
 
     def test_validate_working_fields_in_live_and_new_dataframes_raises_not_in_new(self, mocker):
-        live_df = pd.DataFrame(columns=['field1', 'field2', 'field3'])
-        new_df = pd.DataFrame(columns=['field1', 'field2'])
+        live_df = pd.DataFrame(columns=['field1', 'field2', 'field3', 'join'])
+        new_df = pd.DataFrame(columns=['field1', 'field2', 'join'])
         fields = ['field1', 'field2', 'field3']
 
         updater_mock = mocker.Mock()
         updater_mock.new_dataframe = new_df
+        updater_mock.index_column = 'join'
 
         with pytest.raises(RuntimeError) as exc_info:
-            palletjack.FeatureServiceInlineUpdater._validate_working_fields_in_live_and_new_dataframes(
+            load.FeatureServiceUpdater._validate_working_fields_in_live_and_new_dataframes(
                 updater_mock, live_df, fields
             )
         assert exc_info.value.args[
@@ -835,23 +851,41 @@ def test_validate_working_fields_in_live_and_new_dataframes_raises_not_in_new(se
         ] == 'Field mismatch between defined fields and either new or live data.\nFields not in live data: set()\nFields not in new data: {\'field3\'}'
 
     def test_validate_working_fields_in_live_and_new_dataframes_raises_not_in_both(self, mocker):
-        live_df = pd.DataFrame(columns=['field1', 'field2'])
-        new_df = pd.DataFrame(columns=['field1', 'field2'])
+        live_df = pd.DataFrame(columns=['field1', 'field2', 'join'])
+        new_df = pd.DataFrame(columns=['field1', 'field2', 'join'])
         fields = ['field1', 'field2', 'field3']
 
         updater_mock = mocker.Mock()
         updater_mock.new_dataframe = new_df
+        updater_mock.index_column = 'join'
 
         with pytest.raises(RuntimeError) as exc_info:
-            palletjack.FeatureServiceInlineUpdater._validate_working_fields_in_live_and_new_dataframes(
+            load.FeatureServiceUpdater._validate_working_fields_in_live_and_new_dataframes(
                 updater_mock, live_df, fields
             )
         assert exc_info.value.args[
             0
         ] == 'Field mismatch between defined fields and either new or live data.\nFields not in live data: {\'field3\'}\nFields not in new data: {\'field3\'}'
 
+    def test_validate_working_fields_in_live_and_new_dataframes_raises_join_not_in_either(self, mocker):
+        live_df = pd.DataFrame(columns=['field1', 'field2'])
+        new_df = pd.DataFrame(columns=['field1', 'field2'])
+        fields = ['field1', 'field2']
+
+        updater_mock = mocker.Mock()
+        updater_mock.new_dataframe = new_df
+        updater_mock.index_column = 'join'
+
+        with pytest.raises(RuntimeError) as exc_info:
+            load.FeatureServiceUpdater._validate_working_fields_in_live_and_new_dataframes(
+                updater_mock, live_df, fields
+            )
+        assert exc_info.value.args[
+            0
+        ] == 'Field mismatch between defined fields and either new or live data.\nFields not in live data: {\'join\'}\nFields not in new data: {\'join\'}'
+
 
-class TestFeatureServiceInlineUpdaterIntegrated:
+class TestFeatureServiceUpdaterIntegrated:
 
     def test_update_existing_features_in_hosted_feature_layer_no_matching_rows_returns_0(self, mocker, caplog):
         pd_mock = mocker.Mock()
@@ -868,7 +902,7 @@ def test_update_existing_features_in_hosted_feature_layer_no_matching_rows_retur
             'key': ['c', 'd'],
         })
         gis_mock = mocker.Mock()
-        updater = palletjack.FeatureServiceInlineUpdater(gis_mock, new_dataframe, 'key')
+        updater = load.FeatureServiceUpdater(gis_mock, new_dataframe, 'key')
 
         updated_rows = updater.update_existing_features_in_hosted_feature_layer('1234', ['data', 'key'])
 
@@ -889,7 +923,7 @@ def test_update_existing_features_in_hosted_feature_layer_no_matching_rows_prope
             'key': ['c', 'd'],
         })
         gis_mock = mocker.Mock()
-        updater = palletjack.FeatureServiceInlineUpdater(gis_mock, new_dataframe, 'key')
+        updater = load.FeatureServiceUpdater(gis_mock, new_dataframe, 'key')
 
         updated_rows = updater.update_existing_features_in_hosted_feature_layer('1234', ['data', 'key'])
 
@@ -926,7 +960,7 @@ def test_update_existing_features_in_hosted_feature_layer_all_matching_returns_2
             'deleteResults': [],
         }
         mocker.patch.object(arcgis.features.FeatureLayer, 'fromitem', new=fromitem_function_mock)
-        updater = palletjack.FeatureServiceInlineUpdater(gis_mock, new_dataframe, 'key')
+        updater = load.FeatureServiceUpdater(gis_mock, new_dataframe, 'key')
 
         updated_rows = updater.update_existing_features_in_hosted_feature_layer('1234', ['data', 'key'])
 
@@ -961,7 +995,7 @@ def test_update_existing_features_in_hosted_feature_layer_all_matching_logs_prop
             'deleteResults': [],
         }
         mocker.patch.object(arcgis.features.FeatureLayer, 'fromitem', new=fromitem_function_mock)
-        updater = palletjack.FeatureServiceInlineUpdater(gis_mock, new_dataframe, 'key')
+        updater = load.FeatureServiceUpdater(gis_mock, new_dataframe, 'key')
 
         with caplog.at_level(logging.DEBUG):
             updated_rows = updater.update_existing_features_in_hosted_feature_layer('1234', ['data', 'key'])
@@ -1001,7 +1035,7 @@ def test_update_existing_features_in_hosted_feature_layer_subsets_logging_fields
             'deleteResults': [],
         }
         mocker.patch.object(arcgis.features.FeatureLayer, 'fromitem', new=fromitem_function_mock)
-        updater = palletjack.FeatureServiceInlineUpdater(gis_mock, new_dataframe, 'key')
+        updater = load.FeatureServiceUpdater(gis_mock, new_dataframe, 'key')
 
         with caplog.at_level(logging.DEBUG):
             updated_rows = updater.update_existing_features_in_hosted_feature_layer('1234', ['data', 'key'])
@@ -1011,6 +1045,49 @@ def test_update_existing_features_in_hosted_feature_layer_subsets_logging_fields
             assert "Existing data: {'OBJECTID': 2, 'data': 'bar', 'key': 'b'}" in caplog.text
             assert "New data: {'data': 'BAR', 'key': 'b'}" in caplog.text
 
+    def test_update_existing_features_in_hosted_feature_layer_only_includes_rows_from_new_data(self, mocker):
+        mocker.patch.object(
+            load.pd.DataFrame.spatial,
+            'from_layer',
+            return_value=pd.DataFrame({
+                'OBJECTID': [1, 2],
+                'data': ['foo', 'bar'],
+                'key': ['a', 'b'],
+            })
+        )
+
+        fromitem_function_mock = mocker.patch.object(arcgis.features.FeatureLayer, 'fromitem')
+        fromitem_function_mock.return_value.edit_features.return_value = {
+            'addResults': [],
+            'updateResults': [
+                {
+                    'objectId': 2,
+                    'success': True
+                },
+            ],
+            'deleteResults': [],
+        }
+
+        new_dataframe = pd.DataFrame({
+            'data': ['FOO', 'BAR'],
+            'key': ['x', 'b'],
+        })
+
+        updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'key')
+
+        updated_rows = updater.update_existing_features_in_hosted_feature_layer('1234', ['data', 'key'])
+
+        test_df = pd.DataFrame({
+            'OBJECTID': [2],
+            'key': ['b'],
+            'data': ['BAR'],
+        })
+
+        tm.assert_frame_equal(
+            fromitem_function_mock.return_value.edit_features.call_args.kwargs['updates'].sdf, test_df, check_like=True
+        )
+        assert updated_rows == 1
+
 
 class TestColorRampReclassifier:
 
@@ -1025,7 +1102,7 @@ def test_get_layer_id_returns_match_single_layer(self, mocker):
         get_data_mock = mocker.Mock(return_value=layers)
         webmap_item_mock = mocker.Mock()
         webmap_item_mock.get_data = get_data_mock
-        reclassifier = palletjack.ColorRampReclassifier(webmap_item_mock, 'gis')
+        reclassifier = load.ColorRampReclassifier(webmap_item_mock, 'gis')
 
         layer_id = reclassifier._get_layer_id('foo')
         assert layer_id == 0
@@ -1047,7 +1124,7 @@ def test_get_layer_id_returns_match_many_layers(self, mocker):
         get_data_mock = mocker.Mock(return_value=layers)
         webmap_item_mock = mocker.Mock()
         webmap_item_mock.get_data = get_data_mock
-        reclassifier = palletjack.ColorRampReclassifier(webmap_item_mock, 'gis')
+        reclassifier = load.ColorRampReclassifier(webmap_item_mock, 'gis')
 
         layer_id = reclassifier._get_layer_id('bar')
         assert layer_id == 1
@@ -1069,7 +1146,7 @@ def test_get_layer_id_returns_first_match(self, mocker):
         get_data_mock = mocker.Mock(return_value=layers)
         webmap_item_mock = mocker.Mock()
         webmap_item_mock.get_data = get_data_mock
-        reclassifier = palletjack.ColorRampReclassifier(webmap_item_mock, 'gis')
+        reclassifier = load.ColorRampReclassifier(webmap_item_mock, 'gis')
 
         layer_id = reclassifier._get_layer_id('bar')
         assert layer_id == 1
@@ -1086,7 +1163,7 @@ def test_get_layer_id_raises_error_when_not_found(self, mocker):
         webmap_item_mock = mocker.Mock()
         webmap_item_mock.title = 'test map'
         webmap_item_mock.get_data = get_data_mock
-        reclassifier = palletjack.ColorRampReclassifier(webmap_item_mock, 'gis')
+        reclassifier = load.ColorRampReclassifier(webmap_item_mock, 'gis')
 
         with pytest.raises(ValueError) as error_info:
             layer_id = reclassifier._get_layer_id('foo')
@@ -1096,7 +1173,7 @@ def test_get_layer_id_raises_error_when_not_found(self, mocker):
     def test_calculate_new_stops_with_manual_numbers(self):
         dataframe = pd.DataFrame({'numbers': [100, 300, 500, 700, 900]})
 
-        stops = palletjack.ColorRampReclassifier._calculate_new_stops(dataframe, 'numbers', 5)
+        stops = load.ColorRampReclassifier._calculate_new_stops(dataframe, 'numbers', 5)
 
         assert stops == [100, 279, 458, 637, 816]
 
@@ -1104,7 +1181,7 @@ def test_calculate_new_stops_mismatched_column_raises_error(self):
         dataframe = pd.DataFrame({'numbers': [100, 300, 500, 700, 900]})
 
         with pytest.raises(ValueError) as error_info:
-            stops = palletjack.ColorRampReclassifier._calculate_new_stops(dataframe, 'foo', 5)
+            stops = load.ColorRampReclassifier._calculate_new_stops(dataframe, 'foo', 5)
             assert 'Column `foo` not in dataframe`' in str(error_info)
 
     def test_update_stops_values(self, mocker):
@@ -1137,7 +1214,7 @@ def test_update_stops_values(self, mocker):
         webmap_item_mock.get_data = get_data_mock
         update_mock = mocker.Mock()
         webmap_item_mock.update = update_mock
-        reclassifier = palletjack.ColorRampReclassifier(webmap_item_mock, 'gis')
+        reclassifier = load.ColorRampReclassifier(webmap_item_mock, 'gis')
 
         reclassifier._update_stop_values(0, [100, 200, 300, 400])
 
@@ -1162,9 +1239,7 @@ def test_create_attachment_action_df_adds_for_blank_existing_name(self, mocker):
             'new_path': ['bee/foo.png'],
         })
 
-        ops_df = palletjack.FeatureServiceAttachmentsUpdater._create_attachment_action_df(
-            mocker.Mock(), input_df, 'new_path'
-        )
+        ops_df = load.FeatureServiceAttachmentsUpdater._create_attachment_action_df(mocker.Mock(), input_df, 'new_path')
 
         test_df = pd.DataFrame({
             'NAME': [np.nan],
@@ -1181,9 +1256,7 @@ def test_create_attachment_action_df_overwrites_for_different_existing_name(self
             'new_path': ['bee/foo.png'],
         })
 
-        ops_df = palletjack.FeatureServiceAttachmentsUpdater._create_attachment_action_df(
-            mocker.Mock(), input_df, 'new_path'
-        )
+        ops_df = load.FeatureServiceAttachmentsUpdater._create_attachment_action_df(mocker.Mock(), input_df, 'new_path')
 
         test_df = pd.DataFrame({
             'NAME': ['bar.png'],
@@ -1200,9 +1273,7 @@ def test_create_attachment_action_df_does_nothing_for_same_name(self, mocker):
             'new_path': ['bee/foo.png'],
         })
 
-        ops_df = palletjack.FeatureServiceAttachmentsUpdater._create_attachment_action_df(
-            mocker.Mock(), input_df, 'new_path'
-        )
+        ops_df = load.FeatureServiceAttachmentsUpdater._create_attachment_action_df(mocker.Mock(), input_df, 'new_path')
 
         test_df = pd.DataFrame({
             'NAME': ['foo.png'],
@@ -1220,9 +1291,7 @@ def test_create_attachment_action_df_does_all_three_ops(self, mocker):
             'new_path': ['bee/foo.png', 'bee/baz.png', 'bee/bin.png'],
         })
 
-        ops_df = palletjack.FeatureServiceAttachmentsUpdater._create_attachment_action_df(
-            mocker.Mock(), input_df, 'new_path'
-        )
+        ops_df = load.FeatureServiceAttachmentsUpdater._create_attachment_action_df(mocker.Mock(), input_df, 'new_path')
 
         test_df = pd.DataFrame({
             'NAME': ['foo.png', 'bar.png', np.nan],
@@ -1239,9 +1308,7 @@ def test_create_attachment_action_df_do_nothing_after_others(self, mocker):
             'new_path': ['bee/baz.png', 'bee/bin.png', 'bee/foo.png'],
         })
 
-        ops_df = palletjack.FeatureServiceAttachmentsUpdater._create_attachment_action_df(
-            mocker.Mock(), input_df, 'new_path'
-        )
+        ops_df = load.FeatureServiceAttachmentsUpdater._create_attachment_action_df(mocker.Mock(), input_df, 'new_path')
 
         test_df = pd.DataFrame({
             'NAME': ['bar.png', np.nan, 'foo.png'],
@@ -1265,7 +1332,7 @@ def test_get_live_data_from_join_field_values_only_gets_matching_data(self, mock
             'attachments': ['foo', 'bar'],
         })
 
-        live_data_subset = palletjack.FeatureServiceAttachmentsUpdater._get_live_oid_and_guid_from_join_field_values(
+        live_data_subset = load.FeatureServiceAttachmentsUpdater._get_live_oid_and_guid_from_join_field_values(
             mocker.Mock(), live_features_df, 'attachment_key', attachments_df
         )
 
@@ -1317,7 +1384,7 @@ def test_get_current_attachment_info_by_oid_includes_nans_for_features_wo_attach
             'attachments': ['fee', 'ber', 'boo'],
         })
 
-        current_attachments_df = palletjack.FeatureServiceAttachmentsUpdater._get_current_attachment_info_by_oid(
+        current_attachments_df = load.FeatureServiceAttachmentsUpdater._get_current_attachment_info_by_oid(
             updater_mock, live_data_subset_df
         )
 
@@ -1337,7 +1404,7 @@ def test_get_current_attachment_info_by_oid_includes_nans_for_features_wo_attach
     def test_check_attachment_dataframe_for_invalid_column_names_doesnt_raise_with_valid_names(self, mocker):
         dataframe = pd.DataFrame(columns=['foo', 'bar'])
         invalid_names = ['baz', 'boo']
-        palletjack.FeatureServiceAttachmentsUpdater._check_attachment_dataframe_for_invalid_column_names(
+        load.FeatureServiceAttachmentsUpdater._check_attachment_dataframe_for_invalid_column_names(
             dataframe, invalid_names
         )
 
@@ -1345,7 +1412,7 @@ def test_check_attachment_dataframe_for_invalid_column_names_raises_with_one_inv
         dataframe = pd.DataFrame(columns=['foo', 'bar'])
         invalid_names = ['foo', 'boo']
         with pytest.raises(RuntimeError) as exc_info:
-            palletjack.FeatureServiceAttachmentsUpdater._check_attachment_dataframe_for_invalid_column_names(
+            load.FeatureServiceAttachmentsUpdater._check_attachment_dataframe_for_invalid_column_names(
                 dataframe, invalid_names
             )
         assert exc_info.value.args[0] == 'Attachment dataframe contains the following invalid names: [\'foo\']'
@@ -1354,7 +1421,7 @@ def test_check_attachment_dataframe_for_invalid_column_names_raises_with_all_inv
         dataframe = pd.DataFrame(columns=['foo', 'bar'])
         invalid_names = ['foo', 'bar']
         with pytest.raises(RuntimeError) as exc_info:
-            palletjack.FeatureServiceAttachmentsUpdater._check_attachment_dataframe_for_invalid_column_names(
+            load.FeatureServiceAttachmentsUpdater._check_attachment_dataframe_for_invalid_column_names(
                 dataframe, invalid_names
             )
         assert exc_info.value.args[0] == 'Attachment dataframe contains the following invalid names: [\'foo\', \'bar\']'
@@ -1383,7 +1450,7 @@ def test_add_attachments_by_oid_adds_and_doesnt_warn(self, mocker):
         updater_mock.feature_layer.attachments.add.side_effect = result_dict
 
         with pytest.warns(None) as warning:
-            count = palletjack.FeatureServiceAttachmentsUpdater._add_attachments_by_oid(updater_mock, action_df, 'path')
+            count = load.FeatureServiceAttachmentsUpdater._add_attachments_by_oid(updater_mock, action_df, 'path')
 
         assert count == 2
         assert not warning
@@ -1416,7 +1483,7 @@ def test_add_attachments_by_oid_warns_on_failure_and_doesnt_count_that_one_and_c
         feature_layer_mock = mocker.Mock()
         feature_layer_mock.attachments.add.side_effect = result_dict
 
-        updater = palletjack.FeatureServiceAttachmentsUpdater(mocker.Mock())
+        updater = load.FeatureServiceAttachmentsUpdater(mocker.Mock())
         updater.feature_layer = feature_layer_mock
 
         with pytest.warns(UserWarning, match='Failed to attach path2 to OID 2'):
@@ -1442,7 +1509,7 @@ def test_add_attachments_by_oid_handles_internal_agol_errors(self, mocker, caplo
             },
         ]
 
-        updater = palletjack.FeatureServiceAttachmentsUpdater(mocker.Mock())
+        updater = load.FeatureServiceAttachmentsUpdater(mocker.Mock())
         updater.feature_layer = feature_layer_mock
 
         count = updater._add_attachments_by_oid(action_df, 'path')
@@ -1469,7 +1536,7 @@ def test_add_attachments_by_oid_skips_overwrite_and_nan(self, mocker):
         updater_mock = mocker.Mock()
         updater_mock.feature_layer.attachments.add.side_effect = result_dict
 
-        count = palletjack.FeatureServiceAttachmentsUpdater._add_attachments_by_oid(updater_mock, action_df, 'path')
+        count = load.FeatureServiceAttachmentsUpdater._add_attachments_by_oid(updater_mock, action_df, 'path')
 
         assert updater_mock.feature_layer.attachments.add.call_count == 1
         assert count == 1
@@ -1500,9 +1567,7 @@ def test_overwrite_attachments_by_oid_overwrites_and_doesnt_warn(self, mocker):
         updater_mock.feature_layer.attachments.update.side_effect = result_dict
 
         with pytest.warns(None) as warning:
-            count = palletjack.FeatureServiceAttachmentsUpdater._overwrite_attachments_by_oid(
-                updater_mock, action_df, 'path'
-            )
+            count = load.FeatureServiceAttachmentsUpdater._overwrite_attachments_by_oid(updater_mock, action_df, 'path')
 
         assert count == 2
         assert not warning
@@ -1537,7 +1602,7 @@ def test_overwrite_attachments_by_oid_warns_on_failure_and_doesnt_count_that_one
         feature_layer_mock = mocker.Mock()
         feature_layer_mock.attachments.update.side_effect = result_dict
 
-        updater = palletjack.FeatureServiceAttachmentsUpdater(mocker.Mock())
+        updater = load.FeatureServiceAttachmentsUpdater(mocker.Mock())
         updater.feature_layer = feature_layer_mock
 
         with pytest.warns(UserWarning, match='Failed to update oldname2, attachment ID 22, on OID 2 with path2'):
@@ -1565,7 +1630,7 @@ def test_overwrite_attachments_by_oid_handles_internal_agol_errors(self, mocker,
             },
         ]
 
-        updater = palletjack.FeatureServiceAttachmentsUpdater(mocker.Mock())
+        updater = load.FeatureServiceAttachmentsUpdater(mocker.Mock())
         updater.feature_layer = feature_layer_mock
 
         count = updater._overwrite_attachments_by_oid(action_df, 'path')
@@ -1594,9 +1659,7 @@ def test_overwrite_attachments_by_oid_skips_add_and_nan(self, mocker):
         updater_mock = mocker.Mock()
         updater_mock.feature_layer.attachments.update.side_effect = result_dict
 
-        count = palletjack.FeatureServiceAttachmentsUpdater._overwrite_attachments_by_oid(
-            updater_mock, action_df, 'path'
-        )
+        count = load.FeatureServiceAttachmentsUpdater._overwrite_attachments_by_oid(updater_mock, action_df, 'path')
 
         assert updater_mock.feature_layer.attachments.update.call_count == 1
         assert count == 1
@@ -1608,7 +1671,7 @@ def test_create_attachments_dataframe_subsets_and_crafts_paths_properly(self, mo
             'data': [11., 12., 13.],
         })
 
-        attachment_df = palletjack.FeatureServiceAttachmentsUpdater.build_attachments_dataframe(
+        attachment_df = load.FeatureServiceAttachmentsUpdater.build_attachments_dataframe(
             input_df, 'join', 'pic', '/foo/bar'
         )
 
@@ -1633,7 +1696,7 @@ def test_create_attachments_dataframe_drops_missing_attachments(self, mocker):
             'pic': ['foo.png', None, ''],
         })
 
-        attachment_df = palletjack.FeatureServiceAttachmentsUpdater.build_attachments_dataframe(
+        attachment_df = load.FeatureServiceAttachmentsUpdater.build_attachments_dataframe(
             input_df, 'join', 'pic', '/foo/bar'
         )
 
@@ -1660,9 +1723,9 @@ def test_truncate_existing_data_normal(self, mocker):
             'lastUpdatedTime': 124,
             'status': 'Completed',
         }
-        overwriter = palletjack.FeatureServiceOverwriter(mocker.Mock())
+        overwriter = load.FeatureServiceOverwriter(mocker.Mock())
 
-        palletjack.FeatureServiceOverwriter._truncate_existing_data(overwriter, fl_mock, 0, 'abc')
+        load.FeatureServiceOverwriter._truncate_existing_data(overwriter, fl_mock, 0, 'abc')
 
     def test_truncate_existing_raises_error_on_failure(self, mocker):
         fl_mock = mocker.Mock()
@@ -1671,10 +1734,10 @@ def test_truncate_existing_raises_error_on_failure(self, mocker):
             'lastUpdatedTime': 124,
             'status': 'Foo',
         }
-        overwriter = palletjack.FeatureServiceOverwriter(mocker.Mock())
+        overwriter = load.FeatureServiceOverwriter(mocker.Mock())
 
         with pytest.raises(RuntimeError) as exc_info:
-            palletjack.FeatureServiceOverwriter._truncate_existing_data(overwriter, fl_mock, 0, 'abc')
+            load.FeatureServiceOverwriter._truncate_existing_data(overwriter, fl_mock, 0, 'abc')
 
         assert exc_info.value.args[0] == 'Failed to truncate existing data from layer id 0 in itemid abc'
 
@@ -1687,16 +1750,16 @@ def test_truncate_existing_retries_on_HTTPError(self, mocker):
                 'status': 'Completed',
             }
         ]
-        overwriter = palletjack.FeatureServiceOverwriter(mocker.Mock())
+        overwriter = load.FeatureServiceOverwriter(mocker.Mock())
 
-        palletjack.FeatureServiceOverwriter._truncate_existing_data(overwriter, fl_mock, 0, 'abc')
+        load.FeatureServiceOverwriter._truncate_existing_data(overwriter, fl_mock, 0, 'abc')
 
     def test_append_new_data_doesnt_raise_on_normal(self, mocker):
         mock_df = mocker.Mock()
         mock_fl = mocker.Mock()
         mock_fl.append.return_value = (True, {'message': 'foo'})
 
-        overwriter = palletjack.FeatureServiceOverwriter(mocker.Mock())
+        overwriter = load.FeatureServiceOverwriter(mocker.Mock())
         overwriter._append_new_data(mock_fl, mock_df, 0, 'abc')
 
     def test_append_new_data_retries_on_httperror(self, mocker):
@@ -1704,7 +1767,7 @@ def test_append_new_data_retries_on_httperror(self, mocker):
         mock_fl = mocker.Mock()
         mock_fl.append.side_effect = [urllib.error.HTTPError('a', 'b', 'c', 'd', 'e'), (True, {'message': 'foo'})]
 
-        overwriter = palletjack.FeatureServiceOverwriter(mocker.Mock())
+        overwriter = load.FeatureServiceOverwriter(mocker.Mock())
         overwriter._append_new_data(mock_fl, mock_df, 0, 'abc')
 
     def test_append_new_data_raises_on_False_result(self, mocker):
@@ -1712,7 +1775,7 @@ def test_append_new_data_raises_on_False_result(self, mocker):
         mock_fl = mocker.Mock()
         mock_fl.append.return_value = (False, {'message': 'foo'})
 
-        overwriter = palletjack.FeatureServiceOverwriter(mocker.Mock())
+        overwriter = load.FeatureServiceOverwriter(mocker.Mock())
 
         with pytest.raises(RuntimeError) as exc_info:
             overwriter._append_new_data(mock_fl, mock_df, 'abc', 0)
@@ -1746,7 +1809,7 @@ def test_truncate_and_load_feature_service_normal(self, mocker):
         new_dataframe = pd.DataFrame(columns=['Foo', 'Bar'])
         mocker.patch.object(pd.DataFrame, 'spatial')
 
-        overwriter = palletjack.FeatureServiceOverwriter(mocker.Mock())
+        overwriter = load.FeatureServiceOverwriter(mocker.Mock())
 
         uploaded_features = overwriter.truncate_and_load_feature_service('abc', new_dataframe, 'foo/dir')
 
@@ -1779,7 +1842,7 @@ def test_truncate_and_load_append_fails_reload_works(self, mocker, caplog):
         new_dataframe = pd.DataFrame(columns=['Foo', 'Bar'])
         mocker.patch.object(pd.DataFrame, 'spatial')
 
-        overwriter = palletjack.FeatureServiceOverwriter(mocker.Mock())
+        overwriter = load.FeatureServiceOverwriter(mocker.Mock())
 
         with pytest.raises(
             RuntimeError,
@@ -1817,7 +1880,7 @@ def test_truncate_and_load_field_check_fails_reload_works(self, mocker, caplog):
         new_dataframe = pd.DataFrame(columns=['Foo', 'Bar', 'Baz'])
         mocker.patch.object(pd.DataFrame, 'spatial')
 
-        overwriter = palletjack.FeatureServiceOverwriter(mocker.Mock())
+        overwriter = load.FeatureServiceOverwriter(mocker.Mock())
 
         with pytest.raises(
             RuntimeError,
@@ -1855,9 +1918,9 @@ def test_truncate_and_load_append_fails_reload_fails(self, mocker, caplog):
         new_dataframe = pd.DataFrame(columns=['Foo', 'Bar'])
         mocker.patch.object(pd.DataFrame, 'spatial')
 
-        mocker.patch.object(palletjack.FeatureServiceOverwriter, '_save_truncated_data', return_value='/foo/bar.json')
+        mocker.patch.object(load.FeatureServiceOverwriter, '_save_truncated_data', return_value='/foo/bar.json')
 
-        overwriter = palletjack.FeatureServiceOverwriter(mocker.Mock())
+        overwriter = load.FeatureServiceOverwriter(mocker.Mock())
 
         with pytest.raises(RuntimeError) as exc_info:
             uploaded_features = overwriter.truncate_and_load_feature_service('abc', new_dataframe, 'foo/dir')
@@ -1885,7 +1948,7 @@ def test_save_truncated_data_calls_write_with_json(self, mocker):
         context_manager_mock.return_value.__enter__.return_value = open_mock
         mocker.patch('pathlib.Path.open', new=context_manager_mock)
 
-        palletjack.FeatureServiceOverwriter._save_truncated_data(mocker.Mock(), mock_sdf, 'foo')
+        load.FeatureServiceOverwriter._save_truncated_data(mocker.Mock(), mock_sdf, 'foo')
 
         test_json_string = '{"features": [{"geometry": {"spatialReference": {"wkid": 4326}, "x": 11, "y": 14}, "attributes": {"foo": 1, "x": 11, "y": 14, "OBJECTID": 1}}], "objectIdFieldName": "OBJECTID", "displayFieldName": "OBJECTID", "spatialReference": {"wkid": 4326}, "geometryType": "esriGeometryPoint", "fields": [{"name": "OBJECTID", "type": "esriFieldTypeOID", "alias": "OBJECTID"}, {"name": "foo", "type": "esriFieldTypeInteger", "alias": "foo"}, {"name": "x", "type": "esriFieldTypeInteger", "alias": "x"}, {"name": "y", "type": "esriFieldTypeInteger", "alias": "y"}]}'
 
@@ -1910,59 +1973,66 @@ def test_save_truncated_data_opens_file_with_right_name(self, mocker):
 
         datetime_mock = mocker.Mock()
         datetime_mock.date.today.return_value = 'foo-date'
-        mocker.patch('palletjack.updaters.datetime', new=datetime_mock)
+        mocker.patch('palletjack.load.datetime', new=datetime_mock)
 
         open_mock = mocker.MagicMock()
         context_manager_mock = mocker.MagicMock()
         context_manager_mock.return_value.__enter__.return_value = open_mock
         mocker.patch('pathlib.Path.open', new=context_manager_mock)
 
-        out_path = palletjack.FeatureServiceOverwriter._save_truncated_data(mocker.Mock(), mock_sdf, 'foo')
+        out_path = load.FeatureServiceOverwriter._save_truncated_data(mocker.Mock(), mock_sdf, 'foo')
 
         assert out_path == Path('foo/old_data_foo-date.json')
 
 
-class TestFeatureServiceInlineUpdaterUpsert:
+class TestFeatureServiceUpdaterUpsert:
+
+    def test_upsert_data_calls_append_with_proper_args(self, mocker):
+        mock_fl = mocker.Mock()
+        mock_fl.append.return_value = (True, {'message': 'foo'})
+        mock_df = mocker.Mock()
+        mock_df.spatial.to_featureset.return_value.to_geojson = 'json'
+        mocker.patch('palletjack.utils.sleep')
+
+        load.FeatureServiceUpdater._upsert_data(mocker.Mock(), mock_fl, mock_df, upsert=True)
+
+        mock_fl.append.assert_called_with(
+            upload_format='geojson', edits='json', upsert=True, rollback=True, return_messages=True
+        )
 
-    def test_upsert_new_data_doesnt_raise_on_normal(self, mocker):
+    def test_upsert_data_doesnt_raise_on_normal(self, mocker):
         mock_df = mocker.Mock()
         mock_fl = mocker.Mock()
         mock_fl.append.return_value = (True, {'message': 'foo'})
         mocker.patch('palletjack.utils.rename_columns_for_agol')
-        updater = palletjack.FeatureServiceInlineUpdater(mocker.Mock(), mocker.Mock(), 'foo')
+        updater = load.FeatureServiceUpdater(mocker.Mock(), mocker.Mock(), 'foo')
 
-        updater._upsert_new_data(mock_fl, mock_df, 'abc', 0)
+        updater._upsert_data(mock_fl, mock_df)
 
-    def test_upsert_new_data_retries_on_httperror(self, mocker):
+    def test_upsert_data_retries_on_httperror(self, mocker):
         mock_df = mocker.Mock()
         mock_fl = mocker.Mock()
         mock_fl.append.side_effect = [Exception, (True, {'message': 'foo'})]
         mocker.patch('palletjack.utils.rename_columns_for_agol')
 
-        updater = palletjack.FeatureServiceInlineUpdater(mocker.Mock(), mocker.Mock(), 'foo')
-        updater._upsert_new_data(mock_fl, mock_df, 'abc', 0)
+        updater = load.FeatureServiceUpdater(mocker.Mock(), mocker.Mock(), 'foo')
+        updater._upsert_data(mock_fl, mock_df)
 
-    def test_upsert_new_data_raises_on_False_result(self, mocker):
+    def test_upsert_data_raises_on_False_result(self, mocker):
         mock_df = mocker.Mock()
         mock_fl = mocker.Mock()
         mock_fl.append.return_value = (False, {'message': 'foo'})
         mocker.patch('palletjack.utils.rename_columns_for_agol')
 
-        updater = palletjack.FeatureServiceInlineUpdater(mocker.Mock(), mocker.Mock(), 'foo')
+        updater = load.FeatureServiceUpdater(mocker.Mock(), mocker.Mock(), 'foo')
 
         with pytest.raises(RuntimeError) as exc_info:
-            updater._upsert_new_data(mock_fl, mock_df, 'abc', 0)
+            updater._upsert_data(mock_fl, mock_df)
 
-        assert exc_info.value.args[
-            0] == 'Failed to append data to layer id 0 in itemid abc. Append should have been rolled back.'
+        assert exc_info.value.args[0] == 'Failed to append data. Append operation should have been rolled back.'
 
-    def test_upsert_new_data_in_hosted_feature_layer_normal(self, mocker):
+    def test_append_new_data_to_hosted_feature_layer_normal(self, mocker):
         mock_fl = mocker.Mock()
-        # mock_fl.manager.truncate.return_value = {
-        #     'submissionTime': 123,
-        #     'lastUpdatedTime': 124,
-        #     'status': 'Completed',
-        # }
         mock_fl.properties = {
             'fields': [
                 {
@@ -1989,19 +2059,14 @@ def test_upsert_new_data_in_hosted_feature_layer_normal(self, mocker):
         new_dataframe = pd.DataFrame(columns=['Foo', 'Bar'])
         mocker.patch.object(pd.DataFrame, 'spatial')
 
-        updater = palletjack.FeatureServiceInlineUpdater(mocker.Mock(), new_dataframe, 'Foo')
+        updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'Foo')
 
-        uploaded_features = updater.upsert_new_data_in_hosted_feature_layer('abc')
+        uploaded_features = updater.append_new_data_to_hosted_feature_layer('abc')
 
         assert uploaded_features == 42
 
-    def test_upsert_new_data_in_hosted_feature_layer_handles_agol_field_renaming(self, mocker):
+    def test_append_new_data_to_hosted_feature_layer_handles_agol_field_renaming(self, mocker):
         mock_fl = mocker.Mock()
-        # mock_fl.manager.truncate.return_value = {
-        #     'submissionTime': 123,
-        #     'lastUpdatedTime': 124,
-        #     'status': 'Completed',
-        # }
         mock_fl.properties = {
             'fields': [
                 {
@@ -2027,13 +2092,13 @@ def test_upsert_new_data_in_hosted_feature_layer_handles_agol_field_renaming(sel
         new_dataframe = pd.DataFrame(columns=['Foo field', 'Bar'])
         mocker.patch.object(pd.DataFrame, 'spatial')
 
-        updater = palletjack.FeatureServiceInlineUpdater(mocker.Mock(), new_dataframe, 'Bar')
+        updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'Bar')
 
-        uploaded_features = updater.upsert_new_data_in_hosted_feature_layer('abc')
+        uploaded_features = updater.append_new_data_to_hosted_feature_layer('abc')
 
         assert uploaded_features == 42
 
-    def test_upsert_new_data_in_hosted_feature_layer_handles_manual_field_renaming(self, mocker):
+    def test_append_new_data_to_hosted_feature_layer_handles_manual_field_renaming(self, mocker):
         mock_fl = mocker.Mock()
 
         mock_fl.properties = {
@@ -2062,16 +2127,170 @@ def test_upsert_new_data_in_hosted_feature_layer_handles_manual_field_renaming(s
         field_mapping = {'st_length(shape)': 'Shape__Length'}
         mocker.patch.object(pd.DataFrame, 'spatial')
 
-        updater = palletjack.FeatureServiceInlineUpdater(
-            mocker.Mock(), new_dataframe, 'Bar', field_mapping=field_mapping
-        )
+        updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'Bar', field_mapping=field_mapping)
 
-        uploaded_features = updater.upsert_new_data_in_hosted_feature_layer('abc')
+        uploaded_features = updater.append_new_data_to_hosted_feature_layer('abc')
 
         assert uploaded_features == 42
 
+    def test_upsert_existing_data_in_hosted_feature_layer_autogen_OIDs_dont_get_added(self, mocker):
+
+        mock_fl = mocker.Mock()
+        mock_fl.properties = {
+            'fields': [
+                {'name': 'OBJECTID'},
+                {'name': 'data'},
+                {'name': 'key'},
+            ],
+            'indexes': [{'fields': 'key', 'isUnique': True},]
+        }  # yapf: disable
+        mocker.patch.object(load.arcgis.features.FeatureLayer, 'fromitem', return_value=mock_fl)
+        pd_mock = mocker.patch.object(load.pd.DataFrame.spatial, 'from_layer')
+        pd_mock.return_value = pd.DataFrame({
+            'OBJECTID': [14, 15],
+            'data': ['foo', 'bar'],
+            'key': ['a', 'b'],
+        })
+
+        upserter_mock = mocker.patch.object(load.FeatureServiceUpdater, '_upsert_data')
+
+        new_dataframe = pd.DataFrame({
+            'data': ['FOO', 'BAR'],
+            'key': ['a', 'b'],
+        })
+
+        updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'key')
+
+        updater.upsert_existing_features_in_hosted_feature_layer('abc', ['data', 'key'])
+
+        test_df = pd.DataFrame({
+            'key': ['a', 'b'],
+            'data': ['FOO', 'BAR'],
+        })
+
+        assert upserter_mock.call_args.args[0] == mock_fl
+        tm.assert_frame_equal(upserter_mock.call_args.args[1], test_df, check_like=True)
+        assert upserter_mock.call_args.kwargs == {
+            'upsert': True,
+            'upsert_matching_field': 'key',
+            'append_fields': ['data', 'key']
+        }
+
+    def test_upsert_existing_data_in_hosted_feature_layer_subsets_fields_in_live_df(self, mocker):
+
+        mocker.patch.object(load.arcgis.features.FeatureLayer, 'fromitem')
+
+        pd_mock = mocker.patch.object(load.pd.DataFrame.spatial, 'from_layer')
+        pd_mock.return_value = pd.DataFrame({
+            'OBJECTID': [1, 2],
+            'data': ['foo', 'bar'],
+            'key': ['a', 'b'],
+        })
+
+        new_dataframe = pd.DataFrame({
+            'data': ['FOO', 'BAR'],
+            'key': ['c', 'd'],
+            'extra_column': ['baz', 'boo'],
+        })
+        mocker.patch.object(load.FeatureServiceUpdater, '_validate_working_fields_in_live_and_new_dataframes')
+        mocker.patch.object(load.FeatureServiceUpdater, '_get_common_rows')
+        mocker.patch.object(load.FeatureServiceUpdater, '_upsert_data')
+        mocker.patch.object(load.utils, 'check_index_column_in_feature_layer')
+        mocker.patch.object(load.utils, 'check_field_set_to_unique')
+
+        updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'key')
+
+        updater.upsert_existing_features_in_hosted_feature_layer('abc', ['data', 'key'])
+
+        test_df = pd.DataFrame({
+            'data': ['FOO', 'BAR'],
+            'key': ['c', 'd'],
+        })
+
+        tm.assert_frame_equal(updater.new_dataframe, test_df)
+
+    def test_upsert_existing_data_in_hosted_feature_layer_raises_on_missing_fields(self, mocker):
+
+        mock_fl = mocker.Mock()
+        mock_fl.properties = {
+            'fields': [
+                {'name': 'OBJECTID'},
+                {'name': 'data'},
+                {'name': 'key'},
+            ],
+            'indexes': [{'fields': 'key', 'isUnique': True},]
+        }  # yapf: disable
+        mocker.patch.object(load.arcgis.features.FeatureLayer, 'fromitem', return_value=mock_fl)
+        pd_mock = mocker.patch.object(load.pd.DataFrame.spatial, 'from_layer')
+        pd_mock.return_value = pd.DataFrame({
+            'OBJECTID': [1, 2],
+            'data': ['foo', 'bar'],
+            'key': ['a', 'b'],
+        })
+
+        new_dataframe = pd.DataFrame({
+            'data': ['FOO', 'BAR'],
+            'key': ['a', 'b'],
+        })
+
+        updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'key')
+
+        with pytest.raises(RuntimeError) as error_info:
+            updater.upsert_existing_features_in_hosted_feature_layer('abc', ['data', 'KEY'])
+
+        assert "Field mismatch between defined fields and either new or live data.\nFields not in live data: {'KEY'}\nFields not in new data: {'KEY'}" in str(
+            error_info.value
+        )
+
+    def test_upsert_existing_data_in_hosted_feature_layer_only_includes_rows_from_new_data(self, mocker):
+        mock_fl = mocker.Mock()
+        mock_fl.properties = {
+            'fields': [
+                {'name': 'OBJECTID'},
+                {'name': 'data'},
+                {'name': 'key'},
+            ],
+            'indexes': [
+                {'fields': 'key', 'isUnique': True},
+            ]
+        }  # yapf: disable
+        mocker.patch.object(load.arcgis.features.FeatureLayer, 'fromitem', return_value=mock_fl)
+        mocker.patch.object(
+            load.pd.DataFrame.spatial,
+            'from_layer',
+            return_value=pd.DataFrame({
+                'OBJECTID': [1, 2],
+                'data': ['foo', 'bar'],
+                'key': ['a', 'b'],
+            })
+        )
+
+        upserter_mock = mocker.patch.object(load.FeatureServiceUpdater, '_upsert_data')
+
+        new_dataframe = pd.DataFrame({
+            'data': ['FOO', 'BAR'],
+            'key': ['x', 'b'],
+        })
+
+        updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'key')
+
+        updater.upsert_existing_features_in_hosted_feature_layer('abc', ['data', 'key'])
+
+        test_df = pd.DataFrame({
+            'key': ['b'],
+            'data': ['BAR'],
+        })
+
+        assert upserter_mock.call_args.args[0] == mock_fl
+        tm.assert_frame_equal(upserter_mock.call_args.args[1], test_df, check_like=True)
+        assert upserter_mock.call_args.kwargs == {
+            'upsert': True,
+            'upsert_matching_field': 'key',
+            'append_fields': ['data', 'key']
+        }
+
 
-class TestFeatureServiceInlineUpdaterInit:
+class TestFeatureServiceUpdaterInit:
 
     def test_init_raises_on_missing_index_field(self, mocker):
 
@@ -2079,7 +2298,7 @@ def test_init_raises_on_missing_index_field(self, mocker):
         # mocker.patch.object(pd.DataFrame, 'spatial')
 
         with pytest.raises(KeyError) as exc_info:
-            updater = palletjack.FeatureServiceInlineUpdater(mocker.Mock(), new_dataframe, 'Baz')
+            updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'Baz')
 
         assert exc_info.value.args[0] == 'Index column Baz not found in dataframe columns'
 
@@ -2088,7 +2307,7 @@ def test_init_renames_dataframe_columns(self, mocker):
         new_dataframe = pd.DataFrame(columns=['Foo field', 'Bar'])
         # mocker.patch.object(pd.DataFrame, 'spatial')
 
-        updater = palletjack.FeatureServiceInlineUpdater(mocker.Mock(), new_dataframe, 'Bar')
+        updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'Bar')
 
         assert list(updater.new_dataframe.columns) == ['Foo_field', 'Bar']
 
@@ -2097,6 +2316,6 @@ def test_init_renames_index_column(self, mocker):
         new_dataframe = pd.DataFrame(columns=['Foo field', 'Bar'])
         # mocker.patch.object(pd.DataFrame, 'spatial')
 
-        updater = palletjack.FeatureServiceInlineUpdater(mocker.Mock(), new_dataframe, 'Foo field')
+        updater = load.FeatureServiceUpdater(mocker.Mock(), new_dataframe, 'Foo field')
 
         assert updater.index_column == 'Foo_field'
diff --git a/tests/test_loaders.py b/tests/test_extract.py
similarity index 71%
rename from tests/test_loaders.py
rename to tests/test_extract.py
index 2dadd31..c168685 100644
--- a/tests/test_loaders.py
+++ b/tests/test_extract.py
@@ -1,12 +1,14 @@
 import logging
 from pathlib import Path
 
+import arcgis
+import geopandas as gpd
 import numpy as np
 import pandas as pd
 import pytest
 from pandas import testing as tm
 
-import palletjack
+from palletjack import extract
 
 
 class TestGSheetsLoader:
@@ -20,7 +22,7 @@ def test_load_specific_worksheet_into_dataframe_by_id(self, mocker):
         gsheet_loader_mock = mocker.Mock()
         gsheet_loader_mock.gsheets_client = client_mock
 
-        palletjack.GSheetLoader.load_specific_worksheet_into_dataframe(gsheet_loader_mock, 'foobar', 5)
+        extract.GSheetLoader.load_specific_worksheet_into_dataframe(gsheet_loader_mock, 'foobar', 5)
 
         sheet_mock.worksheet.assert_called_once_with('index', 5)
         sheet_mock.worksheet_by_title.assert_not_called()
@@ -34,9 +36,7 @@ def test_load_specific_worksheet_into_dataframe_by_title(self, mocker):
         gsheet_loader_mock = mocker.Mock()
         gsheet_loader_mock.gsheets_client = client_mock
 
-        palletjack.GSheetLoader.load_specific_worksheet_into_dataframe(
-            gsheet_loader_mock, 'foobar', '2015', by_title=True
-        )
+        extract.GSheetLoader.load_specific_worksheet_into_dataframe(gsheet_loader_mock, 'foobar', '2015', by_title=True)
 
         sheet_mock.worksheet.assert_not_called
         sheet_mock.worksheet_by_title.assert_called_once_with('2015')
@@ -55,7 +55,7 @@ def test_load_all_worksheets_into_dataframes_single_worksheet(self, mocker):
         gsheet_loader_mock = mocker.Mock()
         gsheet_loader_mock.gsheets_client = client_mock
 
-        df_dict = palletjack.GSheetLoader.load_all_worksheets_into_dataframes(gsheet_loader_mock, 'foobar')
+        df_dict = extract.GSheetLoader.load_all_worksheets_into_dataframes(gsheet_loader_mock, 'foobar')
 
         test_dict = {'ws1': 'df1'}
 
@@ -79,7 +79,7 @@ def test_load_all_worksheets_into_dataframes_multiple_worksheets(self, mocker):
         gsheet_loader_mock = mocker.Mock()
         gsheet_loader_mock.gsheets_client = client_mock
 
-        df_dict = palletjack.GSheetLoader.load_all_worksheets_into_dataframes(gsheet_loader_mock, 'foobar')
+        df_dict = extract.GSheetLoader.load_all_worksheets_into_dataframes(gsheet_loader_mock, 'foobar')
 
         test_dict = {'ws1': 'df1', 'ws2': 'df2'}
 
@@ -98,7 +98,7 @@ def test_combine_worksheets_into_single_dataframe_combines_properly(self, mocker
 
         df_dict = {'df1': df1, 'df2': df2}
 
-        combined_df = palletjack.GSheetLoader.combine_worksheets_into_single_dataframe(mocker.Mock(), df_dict)
+        combined_df = extract.GSheetLoader.combine_worksheets_into_single_dataframe(mocker.Mock(), df_dict)
 
         test_df = pd.DataFrame({
             'worksheet': ['df1', 'df1', 'df2', 'df2'],
@@ -123,8 +123,8 @@ def test_combine_worksheets_into_single_dataframe_raises_error_on_mismatched_col
 
         df_dict = {'df1': df1, 'df2': df2}
 
-        with pytest.raises(ValueError, match='Columns do not match; cannot create mutli-index dataframe'):
-            combined_df = palletjack.GSheetLoader.combine_worksheets_into_single_dataframe(mocker.Mock(), df_dict)
+        with pytest.raises(ValueError, match='Columns do not match; cannot create multi-index dataframe'):
+            combined_df = extract.GSheetLoader.combine_worksheets_into_single_dataframe(mocker.Mock(), df_dict)
 
 
 class TestGoogleDriveDownloader:
@@ -136,7 +136,7 @@ def test_get_filename_from_response_works_normally(self, mocker):
             'Content-Disposition': 'attachment;filename="file.name";filename*=UTF-8\'\'file.name'
         }
 
-        filename = palletjack.GoogleDriveDownloader._get_filename_from_response(response)
+        filename = extract.GoogleDriveDownloader._get_filename_from_response(response)
 
         assert filename == 'file.name'
 
@@ -145,7 +145,7 @@ def test_get_filename_from_response_raises_error_if_not_found(self, mocker):
         response.headers = {'foo': 'bar', 'Content-Disposition': 'attachment'}
 
         with pytest.raises(ValueError, match='filename not found in response header'):
-            palletjack.GoogleDriveDownloader._get_filename_from_response(response)
+            extract.GoogleDriveDownloader._get_filename_from_response(response)
 
     def test_get_http_response_works_normally(self, mocker):
         response_mock = mocker.Mock()
@@ -154,7 +154,7 @@ def test_get_http_response_works_normally(self, mocker):
         get_mock.return_value = response_mock
         mocker.patch('requests.get', new=get_mock)
 
-        palletjack.GoogleDriveDownloader._get_http_response(mocker.Mock(), 'foo_file_id')
+        extract.GoogleDriveDownloader._get_http_response(mocker.Mock(), 'foo_file_id')
 
         get_mock.assert_called_with(
             'https://docs.google.com/uc?export=download', params={'id': 'foo_file_id'}, stream=True
@@ -168,7 +168,7 @@ def test_get_http_response_raises_error_on_text_response(self, mocker):
         mocker.patch('requests.get', new=get_mock)
 
         with pytest.raises(RuntimeError) as error:
-            palletjack.GoogleDriveDownloader._get_http_response(mocker.Mock(), 'foo_file_id')
+            extract.GoogleDriveDownloader._get_http_response(mocker.Mock(), 'foo_file_id')
         assert 'Cannot access foo_file_id (is it publicly shared?). Response header in log.' in str(error.value)
 
     def test_save_response_content_skips_empty_chunks(self, mocker):
@@ -179,20 +179,20 @@ def test_save_response_content_skips_empty_chunks(self, mocker):
         open_mock = mocker.mock_open()
         mocker.patch('pathlib.Path.open', open_mock)
 
-        palletjack.GoogleDriveDownloader._save_response_content(response_mock, Path('/foo/bar'), chunk_size=1)
+        extract.GoogleDriveDownloader._save_response_content(response_mock, Path('/foo/bar'), chunk_size=1)
 
         assert open_mock().write.call_args_list[0][0] == (b'\x01',)
         assert open_mock().write.call_args_list[1][0] == (b'\x02',)
 
     def test_download_file_from_google_drive_creates_filename(self, mocker):
 
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_http_response', return_value='response')
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_filename_from_response', return_value='baz.png')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_http_response', return_value='response')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_filename_from_response', return_value='baz.png')
         save_mock = mocker.Mock()
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_save_response_content', save_mock)
+        mocker.patch.object(extract.GoogleDriveDownloader, '_save_response_content', save_mock)
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
         downloader.download_file_from_google_drive('1234', 42)
 
@@ -201,11 +201,11 @@ def test_download_file_from_google_drive_creates_filename(self, mocker):
     def test_download_file_from_google_drive_handles_empty_string_link(self, mocker, caplog):
 
         file_id_mock = mocker.Mock()
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_file_id_from_sharing_link', file_id_mock)
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_file_id_from_sharing_link', file_id_mock)
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
-        caplog.set_level(logging.DEBUG, logger='palletjack.loaders.GoogleDriveDownloader')
+        caplog.set_level(logging.DEBUG, logger='palletjack.extract.GoogleDriveDownloader')
         caplog.clear()
         result = downloader.download_file_from_google_drive('', 42)
 
@@ -216,11 +216,11 @@ def test_download_file_from_google_drive_handles_empty_string_link(self, mocker,
     def test_download_file_from_google_drive_handles_None_link(self, mocker, caplog):
 
         file_id_mock = mocker.Mock()
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_file_id_from_sharing_link', file_id_mock)
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_file_id_from_sharing_link', file_id_mock)
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
-        caplog.set_level(logging.DEBUG, logger='palletjack.loaders.GoogleDriveDownloader')
+        caplog.set_level(logging.DEBUG, logger='palletjack.extract.GoogleDriveDownloader')
         caplog.clear()
         result = downloader.download_file_from_google_drive(None, 42)
 
@@ -230,18 +230,16 @@ def test_download_file_from_google_drive_handles_None_link(self, mocker, caplog)
 
     def test_download_file_from_google_drive_handles_download_error(self, mocker, caplog):
 
-        mocker.patch.object(
-            palletjack.GoogleDriveDownloader, '_get_file_id_from_sharing_link', return_value='google_id'
-        )
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_http_response', side_effect=Exception('Boom'))
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_file_id_from_sharing_link', return_value='google_id')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_http_response', side_effect=Exception('Boom'))
         filename_mock = mocker.Mock()
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_filename_from_response', new=filename_mock)
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_filename_from_response', new=filename_mock)
         # save_mock = mocker.Mock()
-        # mocker.patch.object(palletjack.GoogleDriveDownloader, '_save_response_content', save_mock)
+        # mocker.patch.object(extract.GoogleDriveDownloader, '_save_response_content', save_mock)
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
-        caplog.set_level(logging.DEBUG, logger='palletjack.loaders.GoogleDriveDownloader')
+        caplog.set_level(logging.DEBUG, logger='palletjack.extract.GoogleDriveDownloader')
         caplog.clear()
         result = downloader.download_file_from_google_drive('1234', 42)
 
@@ -255,13 +253,13 @@ def test_download_file_from_google_drive_handles_download_error(self, mocker, ca
         assert result is None
 
     def test_download_file_from_google_drive_pauses_appropriately(self, mocker):
-        sleep_mock = mocker.patch.object(palletjack.loaders, 'sleep')
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_http_response', return_value='response')
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_filename_from_response', return_value='baz.png')
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_save_response_content')
+        sleep_mock = mocker.patch.object(extract, 'sleep')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_http_response', return_value='response')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_filename_from_response', return_value='baz.png')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_save_response_content')
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
         downloader.download_file_from_google_drive('1234', 42, 1.5)
 
@@ -270,13 +268,13 @@ def test_download_file_from_google_drive_pauses_appropriately(self, mocker):
 
     def test_download_attachments_from_dataframe_sleeps_specified_time(self, mocker):
 
-        sleep_mock = mocker.patch.object(palletjack.loaders, 'sleep')
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_http_response', return_value='response')
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_filename_from_response', return_value='baz.png')
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_save_response_content')
+        sleep_mock = mocker.patch.object(extract, 'sleep')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_http_response', return_value='response')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_filename_from_response', return_value='baz.png')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_save_response_content')
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
         sheet_dataframe = pd.DataFrame({
             'join_id': [1],
@@ -298,7 +296,7 @@ def test_download_attachments_from_dataframe_handles_multiple_rows(self, mocker)
             'link': ['a', 'b'],
         })
 
-        downloaded_df = palletjack.GoogleDriveDownloader.download_attachments_from_dataframe(
+        downloaded_df = extract.GoogleDriveDownloader.download_attachments_from_dataframe(
             downloader_mock, sheet_dataframe, 'link', 'join_id', 'path'
         )
 
@@ -312,7 +310,7 @@ def test_download_attachments_from_dataframe_handles_multiple_rows(self, mocker)
 
     def test_get_file_id_from_sharing_link_extracts_group_file_link(self, mocker):
 
-        downloader = palletjack.GoogleDriveDownloader('foo')
+        downloader = extract.GoogleDriveDownloader('foo')
         test_link = 'https://drive.google.com/file/d/abcdefghijklm_opqrstuvwxy/view?usp=sharing'
 
         file_id = downloader._get_file_id_from_sharing_link(test_link)
@@ -321,7 +319,7 @@ def test_get_file_id_from_sharing_link_extracts_group_file_link(self, mocker):
 
     def test_get_file_id_from_sharing_link_extracts_group_file_link_with_dashes(self, mocker):
 
-        downloader = palletjack.GoogleDriveDownloader('foo')
+        downloader = extract.GoogleDriveDownloader('foo')
         test_link = 'https://drive.google.com/file/d/abcdefghijklm-opqrstuvwxy/view?usp=sharing'
 
         file_id = downloader._get_file_id_from_sharing_link(test_link)
@@ -330,7 +328,7 @@ def test_get_file_id_from_sharing_link_extracts_group_file_link_with_dashes(self
 
     def test_get_file_id_from_sharing_link_extracts_group_open_link(self, mocker):
 
-        downloader = palletjack.GoogleDriveDownloader('foo')
+        downloader = extract.GoogleDriveDownloader('foo')
         test_link = 'https://drive.google.com/open?id=abcdefghijklm_opqrstuvwxy'
 
         file_id = downloader._get_file_id_from_sharing_link(test_link)
@@ -339,7 +337,7 @@ def test_get_file_id_from_sharing_link_extracts_group_open_link(self, mocker):
 
     def test_get_file_id_from_sharing_link_raises_error_on_failed_match(self, mocker):
 
-        downloader = palletjack.GoogleDriveDownloader('foo')
+        downloader = extract.GoogleDriveDownloader('foo')
         test_link = 'bad_link'
 
         with pytest.raises(RuntimeError, match='Regex could not match sharing link bad_link'):
@@ -353,7 +351,7 @@ def test_get_request_and_filename_from_drive_api_returns_both(self, mocker):
         client.drive.service.files.return_value.get_media.return_value = 'request'
         client.drive.service.files.return_value.get.return_value.execute.return_value = {'name': 'foo.bar'}
 
-        request, filename = palletjack.GoogleDriveDownloader._get_request_and_filename_from_drive_api(
+        request, filename = extract.GoogleDriveDownloader._get_request_and_filename_from_drive_api(
             mocker.Mock(), client, 'bar'
         )
 
@@ -368,7 +366,7 @@ def test_get_request_and_filename_from_drive_api_adds_extension(self, mocker):
             'mimeType': 'image/jpeg'
         }
 
-        request, filename = palletjack.GoogleDriveDownloader._get_request_and_filename_from_drive_api(
+        request, filename = extract.GoogleDriveDownloader._get_request_and_filename_from_drive_api(
             mocker.Mock(), client, 'bar'
         )
 
@@ -382,7 +380,7 @@ def test_get_request_and_filename_from_drive_api_warns_on_missing_mimeType(self,
             'name': 'foo',
         }
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
         caplog.set_level(logging.DEBUG, logger='palletjack.loaders.GoogleDriveDownloader')
         caplog.clear()
@@ -401,7 +399,7 @@ def test_get_request_and_filename_from_drive_api_warns_on_cant_find_extension(se
             'mimeType': 'bee/boo'
         }
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
         caplog.set_level(logging.DEBUG, logger='palletjack.loaders.GoogleDriveDownloader')
         caplog.clear()
@@ -414,25 +412,25 @@ def test_get_request_and_filename_from_drive_api_warns_on_cant_find_extension(se
         assert filename == 'foo'
 
     def test_save_get_media_content_works_multiple_chunks(self, mocker):
-        mocker.patch.object(palletjack.loaders, 'BytesIO')
-        downloader_mock = mocker.patch.object(palletjack.loaders, 'MediaIoBaseDownload')
+        mocker.patch.object(extract, 'BytesIO')
+        downloader_mock = mocker.patch.object(extract, 'MediaIoBaseDownload')
         downloader_mock.return_value.next_chunk.side_effect = [(1, False), (2, True)]
 
-        palletjack.GoogleDriveDownloader._save_get_media_content(mocker.Mock(), 'foo', mocker.Mock())
+        extract.GoogleDriveDownloader._save_get_media_content(mocker.Mock(), 'foo', mocker.Mock())
 
         assert downloader_mock.return_value.next_chunk.call_count == 2
 
     def test_download_file_from_google_drive_using_api_creates_filename(self, mocker):
 
-        mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
         mocker.patch.object(
-            palletjack.GoogleDriveDownloader,
+            extract.GoogleDriveDownloader,
             '_get_request_and_filename_from_drive_api',
             return_value=('response', 'baz.png')
         )
-        save_mock = mocker.patch.object(palletjack.GoogleDriveDownloader, '_save_get_media_content')
+        save_mock = mocker.patch.object(extract.GoogleDriveDownloader, '_save_get_media_content')
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
         downloader.download_file_from_google_drive_using_api(mocker.Mock(), '1234', 42)
 
@@ -440,11 +438,11 @@ def test_download_file_from_google_drive_using_api_creates_filename(self, mocker
 
     def test_download_file_from_google_drive_using_api_handles_empty_string_link(self, mocker, caplog):
 
-        file_id_mock = mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
+        file_id_mock = mocker.patch.object(extract.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
-        caplog.set_level(logging.DEBUG, logger='palletjack.loaders.GoogleDriveDownloader')
+        caplog.set_level(logging.DEBUG, logger='palletjack.extract.GoogleDriveDownloader')
         caplog.clear()
         result = downloader.download_file_from_google_drive_using_api(mocker.Mock(), '', 42)
 
@@ -454,11 +452,11 @@ def test_download_file_from_google_drive_using_api_handles_empty_string_link(sel
 
     def test_download_file_from_google_drive_using_api_handles_None_link(self, mocker, caplog):
 
-        file_id_mock = mocker.patch.object(palletjack.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
+        file_id_mock = mocker.patch.object(extract.GoogleDriveDownloader, '_get_file_id_from_sharing_link')
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
-        caplog.set_level(logging.DEBUG, logger='palletjack.loaders.GoogleDriveDownloader')
+        caplog.set_level(logging.DEBUG, logger='palletjack.extract.GoogleDriveDownloader')
         caplog.clear()
         result = downloader.download_file_from_google_drive_using_api(mocker.Mock(), None, 42)
 
@@ -468,19 +466,17 @@ def test_download_file_from_google_drive_using_api_handles_None_link(self, mocke
 
     def test_download_file_from_google_drive_using_api_handles_download_error(self, mocker, caplog):
 
+        mocker.patch.object(extract.GoogleDriveDownloader, '_get_file_id_from_sharing_link', return_value='google_id')
+        mocker.patch.object(extract.utils, 'sleep')
         mocker.patch.object(
-            palletjack.GoogleDriveDownloader, '_get_file_id_from_sharing_link', return_value='google_id'
-        )
-        mocker.patch.object(palletjack.utils, 'sleep')
-        mocker.patch.object(
-            palletjack.GoogleDriveDownloader, '_get_request_and_filename_from_drive_api', side_effect=Exception('Boom')
+            extract.GoogleDriveDownloader, '_get_request_and_filename_from_drive_api', side_effect=Exception('Boom')
         )
 
-        saver_mock = mocker.patch.object(palletjack.GoogleDriveDownloader, '_save_get_media_content')
+        saver_mock = mocker.patch.object(extract.GoogleDriveDownloader, '_save_get_media_content')
 
-        downloader = palletjack.GoogleDriveDownloader('/foo/bar')
+        downloader = extract.GoogleDriveDownloader('/foo/bar')
 
-        caplog.set_level(logging.DEBUG, logger='palletjack.loaders.GoogleDriveDownloader')
+        caplog.set_level(logging.DEBUG, logger='palletjack.extract.GoogleDriveDownloader')
         caplog.clear()
         result = downloader.download_file_from_google_drive_using_api(mocker.Mock(), '1234', 42)
 
@@ -495,7 +491,7 @@ def test_download_file_from_google_drive_using_api_handles_download_error(self,
 
     def test_download_attachments_from_dataframe_using_api_handles_multiple_rows(self, mocker):
 
-        mocker.patch.object(palletjack.utils, 'authorize_pygsheets')
+        mocker.patch.object(extract.utils, 'authorize_pygsheets')
 
         downloader_mock = mocker.Mock()
         downloader_mock.download_file_from_google_drive_using_api.side_effect = ['foo/bar.png', 'baz/boo.png']
@@ -505,7 +501,7 @@ def test_download_attachments_from_dataframe_using_api_handles_multiple_rows(sel
             'link': ['a', 'b'],
         })
 
-        downloaded_df = palletjack.GoogleDriveDownloader.download_attachments_from_dataframe_using_api(
+        downloaded_df = extract.GoogleDriveDownloader.download_attachments_from_dataframe_using_api(
             downloader_mock, 'service_file', sheet_dataframe, 'link', 'join_id', 'path'
         )
 
@@ -539,7 +535,7 @@ def test_download_sftp_folder_contents_uses_right_credentials(self, mocker):
         cnopts_mock.side_effect = lambda knownhosts: knownhosts
         mocker.patch('pysftp.CnOpts', new=cnopts_mock)
 
-        palletjack.SFTPLoader.download_sftp_folder_contents(sftploader_mock)
+        extract.SFTPLoader.download_sftp_folder_contents(sftploader_mock)
 
         context_manager_mock.assert_called_with(
             'sftp_host', username='username', password='password', cnopts='knownhosts_file'
@@ -562,7 +558,7 @@ def test_download_sftp_single_file_uses_right_credentials(self, mocker):
         cnopts_mock.side_effect = lambda knownhosts: knownhosts
         mocker.patch('pysftp.CnOpts', new=cnopts_mock)
 
-        palletjack.SFTPLoader.download_sftp_single_file(sftploader_mock, 'filename', 'upload')
+        extract.SFTPLoader.download_sftp_single_file(sftploader_mock, 'filename', 'upload')
 
         context_manager_mock.assert_called_with(
             'sftp_host', username='username', password='password', cnopts='knownhosts_file', default_path='upload'
@@ -578,7 +574,7 @@ def test_read_csv_into_dataframe_with_column_names(self, mocker):
 
         column_types = {'bar': np.float64}
 
-        palletjack.SFTPLoader.read_csv_into_dataframe(sftploader_mock, 'baz', column_types)
+        extract.SFTPLoader.read_csv_into_dataframe(sftploader_mock, 'baz', column_types)
 
         pd_mock.assert_called_with(Path('foo', 'baz'), names=['bar'], dtype=column_types)
 
@@ -590,6 +586,20 @@ def test_read_csv_into_dataframe_no_column_names(self, mocker):
         sftploader_mock = mocker.Mock()
         sftploader_mock.download_dir = 'foo'
 
-        palletjack.SFTPLoader.read_csv_into_dataframe(sftploader_mock, 'baz')
+        extract.SFTPLoader.read_csv_into_dataframe(sftploader_mock, 'baz')
 
         pd_mock.assert_called_with(Path('foo', 'baz'), names=None, dtype=None)
+
+
+class TestPostgresLoader:
+
+    def test_get_postgres_connection(self, mocker):
+
+        mocker.patch.object(
+            gpd, 'read_postgis', return_value=gpd.read_file(gpd.datasets.get_path('naturalearth_lowres'))
+        )
+
+        loader = extract.PostgresLoader('host', 'app', 'user', 'password')
+        dataframe = loader.read_table_into_dataframe('table', 'name', '4326', 'geometry')
+
+        assert dataframe.spatial.geometry_type == ['polygon']
diff --git a/tests/test_load.py b/tests/test_load.py
new file mode 100644
index 0000000..39ac755
--- /dev/null
+++ b/tests/test_load.py
@@ -0,0 +1,1183 @@
+import datetime
+import json
+import logging
+import re
+import sys
+import urllib
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import pandas.testing as tm
+import pytest
+from arcgis import GeoAccessor, GeoSeriesAccessor
+
+from palletjack import load
+
+
+class TestFeatureServiceUpdaterInit:
+
+    def test_init_calls_get_feature_layer(self, mocker):
+        new_dataframe = pd.DataFrame(columns=['Foo field', 'Bar'])
+        arcgis_mock = mocker.patch('palletjack.load.arcgis')
+
+        updater = load.FeatureServiceUpdater(mocker.Mock(), 'itemid', new_dataframe)
+
+        arcgis_mock.features.FeatureLayer.fromitem.assert_called_once()
+
+    def test_init_renames_dataframe_columns(self, mocker):
+
+        new_dataframe = pd.DataFrame(columns=['Foo field', 'Bar'])
+        mocker.patch('palletjack.load.arcgis')
+
+        updater = load.FeatureServiceUpdater(mocker.Mock(), 'itemid', new_dataframe, join_column='Bar')
+
+        assert list(updater.new_dataframe.columns) == ['Foo_field', 'Bar']
+
+    def test_init_renames_fields(self, mocker):
+
+        new_dataframe = pd.DataFrame(columns=['Foo', 'Bar'])
+        mocker.patch('palletjack.load.arcgis')
+        fields = ['Foo field', 'Bar!field', 'baz']
+
+        updater = load.FeatureServiceUpdater(mocker.Mock(), 'itemid', new_dataframe, fields=fields)
+
+        assert set(updater.fields) == {'Foo_field', 'Bar_field', 'baz'}
+
+    def test_init_renames_join_column(self, mocker):
+
+        new_dataframe = pd.DataFrame(columns=['Foo', 'Bar'])
+        mocker.patch('palletjack.load.arcgis')
+
+        updater = load.FeatureServiceUpdater(mocker.Mock(), 'itemid', new_dataframe, join_column='Foo field')
+
+        assert updater.join_column == 'Foo_field'
+
+
+class TestUpdateLayer:
+
+    def test_update_hosted_feature_layer_calls_upsert_correctly(self, mocker):
+        new_dataframe = pd.DataFrame({
+            'foo': [1, 2],
+            'bar': [3, 4],
+            'OBJECTID': [11, 12],
+            'SHAPE': ['s1', 's2'],
+        })
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        fl_mock = mocker.Mock()
+        updater_mock.feature_layer = fl_mock
+        updater_mock.new_dataframe = new_dataframe
+        updater_mock.fields = list(new_dataframe.columns)
+        updater_mock.join_column = 'OBJECTID'
+        updater_mock.layer_index = 0
+
+        updater_mock._upsert_data.return_value = {'recordCount': 1}
+
+        field_checker_mock = mocker.patch('palletjack.utils.FieldChecker')
+
+        load.FeatureServiceUpdater._update_hosted_feature_layer(updater_mock, update_geometry=True)
+
+        updater_mock._upsert_data.assert_called_once_with(
+            fl_mock,
+            new_dataframe,
+            upsert=True,
+            upsert_matching_field='OBJECTID',
+            append_fields=['foo', 'bar', 'OBJECTID', 'SHAPE'],
+            update_geometry=True
+        )
+
+    def test_update_hosted_feature_layer_calls_field_checkers(self, mocker):
+        new_dataframe = pd.DataFrame({
+            'foo': [1, 2],
+            'bar': [3, 4],
+            'OBJECTID': [11, 12],
+        })
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.feature_layer = mocker.Mock()
+        updater_mock.new_dataframe = new_dataframe
+        updater_mock.fields = list(new_dataframe.columns)
+        updater_mock.join_column = 'OBJECTID'
+        updater_mock.layer_index = 0
+
+        updater_mock._upsert_data.return_value = {'recordCount': 1}
+
+        field_checker_mock = mocker.patch('palletjack.utils.FieldChecker')
+
+        load.FeatureServiceUpdater._update_hosted_feature_layer(updater_mock, update_geometry=True)
+
+        field_checker_mock.return_value.check_live_and_new_field_types_match.assert_called_once_with([
+            'foo', 'bar', 'OBJECTID'
+        ])
+        field_checker_mock.return_value.check_for_non_null_fields.assert_called_once_with(['foo', 'bar', 'OBJECTID'])
+        field_checker_mock.return_value.check_field_length.assert_called_once_with(['foo', 'bar', 'OBJECTID'])
+        field_checker_mock.return_value.check_fields_present.assert_called_once_with(['foo', 'bar', 'OBJECTID'],
+                                                                                     add_oid=True)
+        field_checker_mock.return_value.check_srs_match.assert_called_once()
+
+    def test_update_hosted_feature_layer_no_geometry_calls_null_geometry_generator(self, mocker):
+        new_dataframe = pd.DataFrame({
+            'foo': [1, 2],
+            'bar': [3, 4],
+            'OBJECTID': [11, 12],
+        })
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.feature_layer = mocker.Mock()
+        updater_mock.feature_layer.properties = {'geometryType': 'esriGeometryPoint'}
+        updater_mock.new_dataframe = new_dataframe
+        # updater_mock.fields = ['foo', 'bar']
+        updater_mock.join_column = 'OBJECTID'
+        updater_mock.layer_index = 0
+
+        updater_mock._upsert_data.return_value = {'recordCount': 1}
+
+        field_checker_mock = mocker.patch('palletjack.utils.FieldChecker')
+        null_generator_mock = mocker.patch('palletjack.utils.get_null_geometries', return_value='Nullo')
+
+        load.FeatureServiceUpdater._update_hosted_feature_layer(updater_mock, update_geometry=False)
+
+        null_generator_mock.assert_called_once_with({'geometryType': 'esriGeometryPoint'})
+
+    def test_update_hosted_feature_layer_no_geometry_calls_upsert_correctly(self, mocker):
+        new_dataframe = pd.DataFrame({
+            'foo': [1, 2],
+            'bar': [3, 4],
+            'OBJECTID': [11, 12],
+        })
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.feature_layer = mocker.Mock()
+        updater_mock.feature_layer.properties = {'geometryType': 'esriGeometryPoint'}
+        updater_mock.new_dataframe = new_dataframe
+        updater_mock.fields = list(new_dataframe.columns)
+        updater_mock.join_column = 'OBJECTID'
+        updater_mock.layer_index = 0
+
+        updater_mock._upsert_data.return_value = {'recordCount': 1}
+
+        field_checker_mock = mocker.patch('palletjack.utils.FieldChecker')
+        null_generator_mock = mocker.patch('palletjack.utils.get_null_geometries', return_value='Nullo')
+
+        load.FeatureServiceUpdater._update_hosted_feature_layer(updater_mock, update_geometry=False)
+
+        updater_mock._upsert_data.assert_called_once_with(
+            updater_mock.feature_layer,
+            new_dataframe,
+            upsert=True,
+            upsert_matching_field='OBJECTID',
+            append_fields=['foo', 'bar', 'OBJECTID'],
+            update_geometry=False
+        )
+
+
+class TestAddToLayer:
+
+    def test_add_new_data_to_hosted_feature_layer_calls_upsert(self, mocker):
+        new_dataframe = pd.DataFrame({
+            'foo': [1, 2],
+            'bar': [3, 4],
+            # 'OBJECTID': [11, 12],
+        })
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.feature_layer = mocker.Mock()
+        updater_mock.new_dataframe = new_dataframe
+        updater_mock.fields = ['foo', 'bar']
+        # updater_mock.join_column = 'OBJECTID'
+        updater_mock.layer_index = 0
+
+        updater_mock._upsert_data.return_value = {'recordCount': 1}
+
+        field_checker_mock = mocker.patch('palletjack.utils.FieldChecker')
+
+        load.FeatureServiceUpdater._add_new_data_to_hosted_feature_layer(updater_mock)
+
+        updater_mock._upsert_data.assert_called_once_with(
+            updater_mock.feature_layer,
+            new_dataframe,
+            upsert=False,
+        )
+
+    def test_add_new_data_to_hosted_feature_layer_calls_field_checkers(self, mocker):
+        new_dataframe = pd.DataFrame({
+            'foo': [1, 2],
+            'bar': [3, 4],
+            # 'OBJECTID': [11, 12],
+        })
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.feature_layer = mocker.Mock()
+        updater_mock.new_dataframe = new_dataframe
+        updater_mock.fields = list(new_dataframe.columns)
+        # updater_mock.join_column = 'OBJECTID'
+        updater_mock.layer_index = 0
+
+        updater_mock._upsert_data.return_value = {'recordCount': 1}
+
+        field_checker_mock = mocker.patch('palletjack.utils.FieldChecker')
+
+        load.FeatureServiceUpdater._add_new_data_to_hosted_feature_layer(updater_mock)
+
+        field_checker_mock.return_value.check_live_and_new_field_types_match.assert_called_once_with(['foo', 'bar'])
+        field_checker_mock.return_value.check_for_non_null_fields.assert_called_once_with(['foo', 'bar'])
+        field_checker_mock.return_value.check_field_length.assert_called_once_with(['foo', 'bar'])
+        field_checker_mock.return_value.check_fields_present.assert_called_once_with(['foo', 'bar'], add_oid=False)
+        field_checker_mock.return_value.check_srs_match.assert_called_once()
+
+
+class TestDeleteFromLayer:
+
+    def test_delete_data_from_hosted_feature_layer_returns_number_of_deleted_features(self, mocker):
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+        updater_mock.feature_layer.delete_features.return_value = {
+            'deleteResults': [
+                {'objectId': 11, 'uniqueId': 11, 'globalId': None, 'success': True},
+                {'objectId': 17, 'uniqueId': 17, 'globalId': None, 'success': True},
+            ]
+        }  # yapf: disable
+
+        delete_utils_mock = mocker.patch('palletjack.utils.DeleteUtils')
+        delete_utils_mock.check_delete_oids_are_in_live_data.return_value = 0
+
+        deleted = load.FeatureServiceUpdater._delete_data_from_hosted_feature_layer(updater_mock, [11, 17])
+
+        assert deleted == 2
+
+    def test_delete_data_from_hosted_feature_layer_raises_on_failed_delete(self, mocker):
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+        updater_mock.feature_layer.delete_features.return_value = {
+            'deleteResults': [
+                {'objectId': 11, 'uniqueId': 11, 'globalId': None, 'success': True},
+                {'objectId': 17, 'uniqueId': 17, 'globalId': None, 'success': False},
+            ]
+        }  # yapf: disable
+
+        mocker.patch('palletjack.utils.DeleteUtils')
+
+        with pytest.raises(RuntimeError, match=re.escape('The following Object IDs failed to delete: [17]')):
+            deleted = load.FeatureServiceUpdater._delete_data_from_hosted_feature_layer(updater_mock, [11, 17])
+
+    def test_delete_data_from_hosted_feature_layer_runs_proper_check_sequence(self, mocker):
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+        updater_mock.feature_layer.query.return_value = {'objectIdFieldName': 'OBJECTID', 'objectIds': [11, 17]}
+        updater_mock.feature_layer.delete_features.return_value = {
+            'deleteResults': [
+                {'objectId': 11, 'uniqueId': 11, 'globalId': None, 'success': True},
+            ]
+        }  # yapf: disable
+
+        deleted = load.FeatureServiceUpdater._delete_data_from_hosted_feature_layer(updater_mock, [11])
+
+        assert deleted == 1
+
+
+class TestTruncateAndLoadLayer:
+
+    def test_truncate_existing_data_normal(self, mocker):
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+
+        updater_mock.feature_layer.manager.truncate.return_value = {
+            'submissionTime': 123,
+            'lastUpdatedTime': 124,
+            'status': 'Completed',
+        }
+
+        load.FeatureServiceUpdater._truncate_existing_data(updater_mock)
+
+    def test_truncate_existing_raises_error_on_failure(self, mocker):
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+        updater_mock.feature_layer.manager.truncate.return_value = {
+            'submissionTime': 123,
+            'lastUpdatedTime': 124,
+            'status': 'Foo',
+        }
+
+        with pytest.raises(RuntimeError, match='Failed to truncate existing data from layer id 0 in itemid foo123'):
+            load.FeatureServiceUpdater._truncate_existing_data(updater_mock)
+
+    def test_truncate_existing_retries_on_HTTPError(self, mocker):
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+        updater_mock.feature_layer.manager.truncate.side_effect = [
+            urllib.error.HTTPError('url', 'code', 'msg', 'hdrs', 'fp'), {
+                'submissionTime': 123,
+                'lastUpdatedTime': 124,
+                'status': 'Completed',
+            }
+        ]
+
+        load.FeatureServiceUpdater._truncate_existing_data(updater_mock)
+
+    def test_truncate_and_load_feature_service_normal(self, mocker):
+        updater_mock = mocker.Mock()
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+        updater_mock.failsafe_dir = ''
+
+        updater_mock.new_dataframe = pd.DataFrame(columns=['Foo', 'Bar'])
+
+        mocker.patch('palletjack.utils.FieldChecker')
+
+        updater_mock._upsert_data.return_value = 42
+
+        uploaded_features = load.FeatureServiceUpdater._truncate_and_load_data(updater_mock)
+
+        assert uploaded_features == 42
+
+    def test_truncate_and_load_append_fails_failsafe_dir_present(self, mocker, caplog):
+        caplog.set_level(logging.DEBUG)
+
+        updater_mock = mocker.Mock()
+        updater_mock._class_logger = logging.getLogger('mock logger')
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+        updater_mock.new_dataframe = pd.DataFrame(columns=['Foo', 'Bar'])
+        updater_mock.failsafe_dir = 'foo'
+        updater_mock.feature_layer.feature_layer.properties.name = 'foolayer'
+
+        mocker.patch('palletjack.utils.FieldChecker')
+        mocker.patch('palletjack.utils.sleep')
+        mocker.patch('palletjack.utils.save_feature_layer_to_json', return_value='bar_path')
+
+        updater_mock._upsert_data.side_effect = RuntimeError(
+            'Failed to append data. Append operation should have been rolled back.'
+        )
+
+        with pytest.raises(RuntimeError, match='Failed to append data. Append operation should have been rolled back.'):
+            uploaded_features = load.FeatureServiceUpdater._truncate_and_load_data(updater_mock)
+
+        assert updater_mock._upsert_data.call_args_list[0].args == (
+            updater_mock.feature_layer, updater_mock.new_dataframe
+        )
+        assert updater_mock._upsert_data.call_args_list[0].kwargs == {'upsert': False}
+
+        assert f'Append failed, feature service may be dirty due to append chunking. Data saved to bar_path' in caplog.text
+
+    def test_truncate_and_load_append_fails_failsafe_dir_absent(self, mocker, caplog):
+        caplog.set_level(logging.DEBUG)
+
+        updater_mock = mocker.Mock()
+        updater_mock._class_logger = logging.getLogger('mock logger')
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+        updater_mock.new_dataframe = pd.DataFrame(columns=['Foo', 'Bar'])
+        updater_mock.failsafe_dir = ''
+
+        mocker.patch('palletjack.utils.FieldChecker')
+        mocker.patch('palletjack.utils.sleep')
+        mocker.patch('palletjack.utils.save_feature_layer_to_json', return_value='bar_path')
+
+        updater_mock._upsert_data.side_effect = RuntimeError(
+            'Failed to append data. Append operation should have been rolled back.'
+        )
+
+        with pytest.raises(RuntimeError, match='Failed to append data. Append operation should have been rolled back.'):
+            uploaded_features = load.FeatureServiceUpdater._truncate_and_load_data(updater_mock)
+
+        assert updater_mock._upsert_data.call_args_list[0].args == (
+            updater_mock.feature_layer, updater_mock.new_dataframe
+        )
+        assert updater_mock._upsert_data.call_args_list[0].kwargs == {'upsert': False}
+
+        assert f'Append failed, feature service may be dirty due to append chunking. Old data not saved (no failsafe dir set)' in caplog.text
+
+    def test_truncate_and_load_calls_field_checkers(self, mocker, caplog):
+
+        caplog.set_level(logging.DEBUG)
+
+        updater_mock = mocker.Mock()
+        updater_mock._class_logger = logging.getLogger('mock logger')
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+        updater_mock.fields = ['Foo', 'Bar']
+        updater_mock.failsafe_dir = ''
+
+        mocker.patch('palletjack.utils.sleep')
+        field_checker_mock = mocker.patch('palletjack.utils.FieldChecker')
+
+        updater_mock._upsert_data.return_value = 42
+
+        uploaded_features = load.FeatureServiceUpdater._truncate_and_load_data(updater_mock)
+
+        field_checker_mock.return_value.check_live_and_new_field_types_match.assert_called_once_with(['Foo', 'Bar'])
+        field_checker_mock.return_value.check_for_non_null_fields.assert_called_once_with(['Foo', 'Bar'])
+        field_checker_mock.return_value.check_field_length.assert_called_once_with(['Foo', 'Bar'])
+        field_checker_mock.return_value.check_fields_present.assert_called_once_with(['Foo', 'Bar'], add_oid=False)
+        field_checker_mock.return_value.check_srs_match.assert_called_once()
+        assert uploaded_features == 42
+
+    def test_truncate_and_load_raises_on_empty_column(self, mocker, caplog):
+
+        caplog.set_level(logging.DEBUG)
+
+        new_dataframe = pd.DataFrame({
+            'id': [0, 1, 2],
+            'floats': [np.nan, np.nan, np.nan],
+            'x': [21, 22, 23],
+            'y': [31, 32, 33]
+        })
+        spatial_df = pd.DataFrame.spatial.from_xy(new_dataframe, 'x', 'y')
+
+        updater_mock = mocker.Mock()
+        updater_mock.new_dataframe = spatial_df
+        updater_mock._class_logger = logging.getLogger('mock logger')
+        updater_mock.feature_service_itemid = 'foo123'
+        updater_mock.layer_index = 0
+        updater_mock.fields = ['id', 'floats', 'x', 'y']
+        updater_mock.failsafe_dir = ''
+
+        updater_mock.feature_layer.properties.fields = [
+            {
+                'name': 'id',
+                'type': 'esriFieldTypeInteger',
+                'nullable': True,
+                'defaultValue': None,
+            },
+            {
+                'name': 'floats',
+                'type': 'esriFieldTypeDouble',
+                'nullable': True,
+                'defaultValue': None,
+            },
+            {
+                'name': 'x',
+                'type': 'esriFieldTypeInteger',
+                'nullable': True,
+                'defaultValue': None,
+            },
+            {
+                'name': 'y',
+                'type': 'esriFieldTypeInteger',
+                'nullable': True,
+                'defaultValue': None,
+            },
+        ]
+        updater_mock.feature_layer.properties.geometryType = 'esriGeometryPoint'
+        updater_mock.feature_layer.properties.extent.spatialReference.latestWkid = 4326
+
+        mocker.patch('palletjack.utils.sleep')
+
+        updater_mock._upsert_data.return_value = {'recordCount': 42}
+
+        with pytest.raises(
+            ValueError,
+            match=re.escape(
+                'The following float/double column(s) are completely empty: [\'floats\'] (suggestion: insert at least one bogus value)'
+            )
+        ):
+            uploaded_features = load.FeatureServiceUpdater._truncate_and_load_data(updater_mock)
+
+
+class TestAttachments:
+
+    def test_create_attachment_action_df_adds_for_blank_existing_name(self, mocker):
+        input_df = pd.DataFrame({
+            'NAME': [np.nan],
+            'new_path': ['bee/foo.png'],
+        })
+
+        ops_df = load.FeatureServiceAttachmentsUpdater._create_attachment_action_df(mocker.Mock(), input_df, 'new_path')
+
+        test_df = pd.DataFrame({
+            'NAME': [np.nan],
+            'new_path': ['bee/foo.png'],
+            'new_filename': ['foo.png'],
+            'operation': ['add'],
+        })
+
+        tm.assert_frame_equal(ops_df, test_df)
+
+    def test_create_attachment_action_df_overwrites_for_different_existing_name(self, mocker):
+        input_df = pd.DataFrame({
+            'NAME': ['bar.png'],
+            'new_path': ['bee/foo.png'],
+        })
+
+        ops_df = load.FeatureServiceAttachmentsUpdater._create_attachment_action_df(mocker.Mock(), input_df, 'new_path')
+
+        test_df = pd.DataFrame({
+            'NAME': ['bar.png'],
+            'new_path': ['bee/foo.png'],
+            'new_filename': ['foo.png'],
+            'operation': ['overwrite'],
+        })
+
+        tm.assert_frame_equal(ops_df, test_df)
+
+    def test_create_attachment_action_df_does_nothing_for_same_name(self, mocker):
+        input_df = pd.DataFrame({
+            'NAME': ['foo.png'],
+            'new_path': ['bee/foo.png'],
+        })
+
+        ops_df = load.FeatureServiceAttachmentsUpdater._create_attachment_action_df(mocker.Mock(), input_df, 'new_path')
+
+        test_df = pd.DataFrame({
+            'NAME': ['foo.png'],
+            'new_path': ['bee/foo.png'],
+            'new_filename': ['foo.png'],
+            'operation': [np.nan],
+        })
+        test_df['operation'] = test_df['operation'].astype(object)
+
+        tm.assert_frame_equal(ops_df, test_df)
+
+    def test_create_attachment_action_df_does_all_three_ops(self, mocker):
+        input_df = pd.DataFrame({
+            'NAME': ['foo.png', 'bar.png', np.nan],
+            'new_path': ['bee/foo.png', 'bee/baz.png', 'bee/bin.png'],
+        })
+
+        ops_df = load.FeatureServiceAttachmentsUpdater._create_attachment_action_df(mocker.Mock(), input_df, 'new_path')
+
+        test_df = pd.DataFrame({
+            'NAME': ['foo.png', 'bar.png', np.nan],
+            'new_path': ['bee/foo.png', 'bee/baz.png', 'bee/bin.png'],
+            'new_filename': ['foo.png', 'baz.png', 'bin.png'],
+            'operation': [np.nan, 'overwrite', 'add'],
+        })
+
+        tm.assert_frame_equal(ops_df, test_df)
+
+    def test_create_attachment_action_df_do_nothing_after_others(self, mocker):
+        input_df = pd.DataFrame({
+            'NAME': ['bar.png', np.nan, 'foo.png'],
+            'new_path': ['bee/baz.png', 'bee/bin.png', 'bee/foo.png'],
+        })
+
+        ops_df = load.FeatureServiceAttachmentsUpdater._create_attachment_action_df(mocker.Mock(), input_df, 'new_path')
+
+        test_df = pd.DataFrame({
+            'NAME': ['bar.png', np.nan, 'foo.png'],
+            'new_path': ['bee/baz.png', 'bee/bin.png', 'bee/foo.png'],
+            'new_filename': ['baz.png', 'bin.png', 'foo.png'],
+            'operation': ['overwrite', 'add', np.nan],
+        })
+
+        tm.assert_frame_equal(ops_df, test_df)
+
+    def test_get_live_data_from_join_field_values_only_gets_matching_data(self, mocker):
+        live_features_df = pd.DataFrame({
+            'OBJECTID': [1, 2, 3],
+            'GlobalID': ['guid1', 'guid2', 'guid3'],
+            'attachment_key': [11, 12, 13],
+            'deleted': ['a', 'b', 'c'],
+        })
+
+        attachments_df = pd.DataFrame({
+            'attachment_key': [12, 13],
+            'attachments': ['foo', 'bar'],
+        })
+
+        live_data_subset = load.FeatureServiceAttachmentsUpdater._get_live_oid_and_guid_from_join_field_values(
+            mocker.Mock(), live_features_df, 'attachment_key', attachments_df
+        )
+
+        test_df = pd.DataFrame({
+            'OBJECTID': [2, 3],
+            'GlobalID': ['guid2', 'guid3'],
+            'attachment_key': [12, 13],
+            'attachments': ['foo', 'bar'],
+        })
+
+        tm.assert_frame_equal(live_data_subset, test_df)
+
+    def test_get_current_attachment_info_by_oid_includes_nans_for_features_wo_attachments(self, mocker):
+
+        live_attachments = [
+            {
+                'PARENTOBJECTID': 1,
+                'PARENTGLOBALID': 'parentguid1',
+                'ID': 111,
+                'NAME': 'foo.png',
+                'CONTENTTYPE': 'image/png',
+                'SIZE': 42,
+                'KEYWORDS': '',
+                'IMAGE_PREVIEW': 'preview1',
+                'GLOBALID': 'guid1',
+                'DOWNLOAD_URL': 'url1'
+            },
+            {
+                'PARENTOBJECTID': 2,
+                'PARENTGLOBALID': 'parentguid2',
+                'ID': 222,
+                'NAME': 'bar.png',
+                'CONTENTTYPE': 'image/png',
+                'SIZE': 42,
+                'KEYWORDS': '',
+                'IMAGE_PREVIEW': 'preview2',
+                'GLOBALID': 'guid2',
+                'DOWNLOAD_URL': 'url2'
+            },
+        ]
+
+        updater_mock = mocker.Mock()
+        updater_mock.feature_layer.attachments.search.return_value = live_attachments
+
+        live_data_subset_df = pd.DataFrame({
+            'OBJECTID': [1, 2, 3],
+            'GlobalID': ['guid1', 'guid2', 'guid3'],
+            'attachment_key': [11, 12, 13],
+            'attachments': ['fee', 'ber', 'boo'],
+        })
+
+        current_attachments_df = load.FeatureServiceAttachmentsUpdater._get_current_attachment_info_by_oid(
+            updater_mock, live_data_subset_df
+        )
+
+        test_df = pd.DataFrame({
+            'OBJECTID': [1, 2, 3],
+            'GlobalID': ['guid1', 'guid2', 'guid3'],
+            'attachment_key': [11, 12, 13],
+            'attachments': ['fee', 'ber', 'boo'],
+            'PARENTOBJECTID': [1., 2., np.nan],
+            'NAME': ['foo.png', 'bar.png', np.nan],
+            'ID': [111, 222, pd.NA],
+        })
+        test_df['ID'] = test_df['ID'].astype('Int64')
+
+        tm.assert_frame_equal(current_attachments_df, test_df)
+
+    def test_check_attachment_dataframe_for_invalid_column_names_doesnt_raise_with_valid_names(self, mocker):
+        dataframe = pd.DataFrame(columns=['foo', 'bar'])
+        invalid_names = ['baz', 'boo']
+        load.FeatureServiceAttachmentsUpdater._check_attachment_dataframe_for_invalid_column_names(
+            dataframe, invalid_names
+        )
+
+    def test_check_attachment_dataframe_for_invalid_column_names_raises_with_one_invalid(self, mocker):
+        dataframe = pd.DataFrame(columns=['foo', 'bar'])
+        invalid_names = ['foo', 'boo']
+        with pytest.raises(RuntimeError) as exc_info:
+            load.FeatureServiceAttachmentsUpdater._check_attachment_dataframe_for_invalid_column_names(
+                dataframe, invalid_names
+            )
+        assert exc_info.value.args[0] == 'Attachment dataframe contains the following invalid names: [\'foo\']'
+
+    def test_check_attachment_dataframe_for_invalid_column_names_raises_with_all_invalid(self, mocker):
+        dataframe = pd.DataFrame(columns=['foo', 'bar'])
+        invalid_names = ['foo', 'bar']
+        with pytest.raises(RuntimeError) as exc_info:
+            load.FeatureServiceAttachmentsUpdater._check_attachment_dataframe_for_invalid_column_names(
+                dataframe, invalid_names
+            )
+        assert exc_info.value.args[0] == 'Attachment dataframe contains the following invalid names: [\'foo\', \'bar\']'
+
+    def test_add_attachments_by_oid_adds_and_doesnt_warn(self, mocker):
+        action_df = pd.DataFrame({
+            'OBJECTID': [1, 2],
+            'operation': ['add', 'add'],
+            'path': ['path1', 'path2'],
+        })
+
+        result_dict = [
+            {
+                'addAttachmentResult': {
+                    'success': True
+                }
+            },
+            {
+                'addAttachmentResult': {
+                    'success': True
+                }
+            },
+        ]
+
+        updater_mock = mocker.Mock()
+        updater_mock.feature_layer.attachments.add.side_effect = result_dict
+
+        with pytest.warns(None) as warning:
+            count = load.FeatureServiceAttachmentsUpdater._add_attachments_by_oid(updater_mock, action_df, 'path')
+
+        assert count == 2
+        assert not warning
+
+    def test_add_attachments_by_oid_warns_on_failure_and_doesnt_count_that_one_and_continues(self, mocker):
+        action_df = pd.DataFrame({
+            'OBJECTID': [1, 2, 3],
+            'operation': ['add', 'add', 'add'],
+            'path': ['path1', 'path2', 'path3'],
+        })
+
+        result_dict = [
+            {
+                'addAttachmentResult': {
+                    'success': True
+                }
+            },
+            {
+                'addAttachmentResult': {
+                    'success': False
+                }
+            },
+            {
+                'addAttachmentResult': {
+                    'success': True
+                }
+            },
+        ]
+
+        feature_layer_mock = mocker.Mock()
+        feature_layer_mock.attachments.add.side_effect = result_dict
+
+        updater = load.FeatureServiceAttachmentsUpdater(mocker.Mock())
+        updater.feature_layer = feature_layer_mock
+
+        with pytest.warns(UserWarning, match='Failed to attach path2 to OID 2'):
+            count = updater._add_attachments_by_oid(action_df, 'path')
+
+        assert count == 2
+        assert updater.failed_dict == {2: ('add', 'path2')}
+
+    def test_add_attachments_by_oid_handles_internal_agol_errors(self, mocker, caplog):
+        action_df = pd.DataFrame({
+            'OBJECTID': [1, 2],
+            'operation': ['add', 'add'],
+            'path': ['path1', 'path2'],
+        })
+
+        feature_layer_mock = mocker.Mock()
+        feature_layer_mock.attachments.add.side_effect = [
+            RuntimeError('foo'),
+            {
+                'addAttachmentResult': {
+                    'success': True
+                }
+            },
+        ]
+
+        updater = load.FeatureServiceAttachmentsUpdater(mocker.Mock())
+        updater.feature_layer = feature_layer_mock
+
+        count = updater._add_attachments_by_oid(action_df, 'path')
+        assert count == 1
+        assert 'AGOL error while adding path1 to OID 1' in caplog.text
+        assert 'foo' in caplog.text
+        assert updater.failed_dict == {1: ('add', 'path1')}
+
+    def test_add_attachments_by_oid_skips_overwrite_and_nan(self, mocker):
+        action_df = pd.DataFrame({
+            'OBJECTID': [1, 2, 3],
+            'operation': ['add', 'overwrite', np.nan],
+            'path': ['path1', 'path2', 'path3'],
+        })
+
+        result_dict = [
+            {
+                'addAttachmentResult': {
+                    'success': True
+                }
+            },
+        ]
+
+        updater_mock = mocker.Mock()
+        updater_mock.feature_layer.attachments.add.side_effect = result_dict
+
+        count = load.FeatureServiceAttachmentsUpdater._add_attachments_by_oid(updater_mock, action_df, 'path')
+
+        assert updater_mock.feature_layer.attachments.add.call_count == 1
+        assert count == 1
+
+    def test_overwrite_attachments_by_oid_overwrites_and_doesnt_warn(self, mocker):
+        action_df = pd.DataFrame({
+            'OBJECTID': [1, 2],
+            'operation': ['overwrite', 'overwrite'],
+            'path': ['path1', 'path2'],
+            'ID': ['existing1', 'existing2'],
+            'NAME': ['oldname1', 'oldname2'],
+        })
+
+        result_dict = [
+            {
+                'updateAttachmentResult': {
+                    'success': True
+                }
+            },
+            {
+                'updateAttachmentResult': {
+                    'success': True
+                }
+            },
+        ]
+
+        updater_mock = mocker.Mock()
+        updater_mock.feature_layer.attachments.update.side_effect = result_dict
+
+        with pytest.warns(None) as warning:
+            count = load.FeatureServiceAttachmentsUpdater._overwrite_attachments_by_oid(updater_mock, action_df, 'path')
+
+        assert count == 2
+        assert not warning
+
+    def test_overwrite_attachments_by_oid_warns_on_failure_and_doesnt_count_that_one_and_continues(self, mocker):
+        action_df = pd.DataFrame({
+            'OBJECTID': [1, 2, 3],
+            'operation': ['overwrite', 'overwrite', 'overwrite'],
+            'path': ['path1', 'path2', 'path3'],
+            'ID': [11, 22, 33],
+            'NAME': ['oldname1', 'oldname2', 'oldname3'],
+        })
+
+        result_dict = [
+            {
+                'updateAttachmentResult': {
+                    'success': True
+                }
+            },
+            {
+                'updateAttachmentResult': {
+                    'success': False
+                }
+            },
+            {
+                'updateAttachmentResult': {
+                    'success': True
+                }
+            },
+        ]
+
+        feature_layer_mock = mocker.Mock()
+        feature_layer_mock.attachments.update.side_effect = result_dict
+
+        updater = load.FeatureServiceAttachmentsUpdater(mocker.Mock())
+        updater.feature_layer = feature_layer_mock
+
+        with pytest.warns(UserWarning, match='Failed to update oldname2, attachment ID 22, on OID 2 with path2'):
+            count = updater._overwrite_attachments_by_oid(action_df, 'path')
+
+        assert count == 2
+        assert updater.failed_dict == {2: ('update', 'path2')}
+
+    def test_overwrite_attachments_by_oid_handles_internal_agol_errors(self, mocker, caplog):
+        action_df = pd.DataFrame({
+            'OBJECTID': [1, 2],
+            'operation': ['overwrite', 'overwrite'],
+            'path': ['path1', 'path2'],
+            'ID': [11, 22],
+            'NAME': ['oldname1', 'oldname2'],
+        })
+
+        feature_layer_mock = mocker.Mock()
+        feature_layer_mock.attachments.update.side_effect = [
+            RuntimeError('foo'),
+            {
+                'updateAttachmentResult': {
+                    'success': True
+                }
+            },
+        ]
+
+        updater = load.FeatureServiceAttachmentsUpdater(mocker.Mock())
+        updater.feature_layer = feature_layer_mock
+
+        count = updater._overwrite_attachments_by_oid(action_df, 'path')
+        assert count == 1
+        assert 'AGOL error while overwriting oldname1 (attachment ID 11) on OID 1 with path1' in caplog.text
+        assert 'foo' in caplog.text
+        assert updater.failed_dict == {1: ('update', 'path1')}
+
+    def test_overwrite_attachments_by_oid_skips_add_and_nan(self, mocker):
+        action_df = pd.DataFrame({
+            'OBJECTID': [1, 2, 3],
+            'operation': ['add', 'overwrite', np.nan],
+            'path': ['path1', 'path2', 'path3'],
+            'ID': [np.nan, 'existing2', 'existing3'],
+            'NAME': ['oldname1', 'oldname2', 'oldname3'],
+        })
+
+        result_dict = [
+            {
+                'updateAttachmentResult': {
+                    'success': True
+                }
+            },
+        ]
+
+        updater_mock = mocker.Mock()
+        updater_mock.feature_layer.attachments.update.side_effect = result_dict
+
+        count = load.FeatureServiceAttachmentsUpdater._overwrite_attachments_by_oid(updater_mock, action_df, 'path')
+
+        assert updater_mock.feature_layer.attachments.update.call_count == 1
+        assert count == 1
+
+    def test_create_attachments_dataframe_subsets_and_crafts_paths_properly(self, mocker):
+        input_df = pd.DataFrame({
+            'join': [1, 2, 3],
+            'pic': ['foo.png', 'bar.png', 'baz.png'],
+            'data': [11., 12., 13.],
+        })
+
+        attachment_df = load.FeatureServiceAttachmentsUpdater.build_attachments_dataframe(
+            input_df, 'join', 'pic', '/foo/bar'
+        )
+
+        test_df = pd.DataFrame({
+            'join': [1, 2, 3],
+            'pic': ['foo.png', 'bar.png', 'baz.png'],
+            'full_file_path': [Path('/foo/bar/foo.png'),
+                               Path('/foo/bar/bar.png'),
+                               Path('/foo/bar/baz.png')]
+        })
+
+        #: Column of path objects won't test equal in assert_frame_equal, so we make lists of their str representations
+        #: and compare those separately from the rest of the dataframe
+        other_fields = ['join', 'pic']
+        tm.assert_frame_equal(attachment_df[other_fields], test_df[other_fields])
+        assert [str(path) for path in attachment_df['full_file_path']
+               ] == [str(path) for path in test_df['full_file_path']]
+
+    def test_create_attachments_dataframe_drops_missing_attachments(self, mocker):
+        input_df = pd.DataFrame({
+            'join': [1, 2, 3],
+            'pic': ['foo.png', None, ''],
+        })
+
+        attachment_df = load.FeatureServiceAttachmentsUpdater.build_attachments_dataframe(
+            input_df, 'join', 'pic', '/foo/bar'
+        )
+
+        test_df = pd.DataFrame({
+            'join': [1],
+            'pic': ['foo.png'],
+            'full_file_path': [Path('/foo/bar/foo.png')],
+        })
+
+        #: Column of path objects won't test equal in assert_frame_equal, so we make lists of their str representations
+        #: and compare those separately from the rest of the dataframe
+        other_fields = ['join', 'pic']
+        tm.assert_frame_equal(attachment_df[other_fields], test_df[other_fields])
+        assert [str(path) for path in attachment_df['full_file_path']
+               ] == [str(path) for path in test_df['full_file_path']]
+
+
+class TestUpsertData:
+
+    def test_upsert_data_calls_append_with_proper_args(self, mocker):
+        mock_fl = mocker.Mock()
+        mock_fl.append.return_value = (True, {'recordCount': 42})
+        mocker.patch('palletjack.utils.sleep')
+        mocker.patch('palletjack.utils.Chunking.build_upload_json', autospec=True, return_value=['json'])
+
+        load.FeatureServiceUpdater._upsert_data(mocker.Mock(), mock_fl, mocker.Mock(), upsert=True)
+
+        mock_fl.append.assert_called_once_with(
+            upload_format='geojson', edits='json', upsert=True, rollback=True, return_messages=True
+        )
+
+    def test_upsert_data_calls_append_for_multiple_chunks(self, mocker):
+        mock_fl = mocker.Mock()
+        mock_fl.append.return_value = (True, {'recordCount': 42})
+        mocker.patch('palletjack.utils.sleep')
+        mocker.patch('palletjack.utils.Chunking.build_upload_json', autospec=True, return_value=['json1', 'json2'])
+
+        load.FeatureServiceUpdater._upsert_data(mocker.Mock(), mock_fl, mocker.Mock(), upsert=True)
+
+        assert mock_fl.append.call_count == 2
+        assert mock_fl.append.call_args_list == [
+            mocker.call(upload_format='geojson', edits='json1', upsert=True, rollback=True, return_messages=True),
+            mocker.call(upload_format='geojson', edits='json2', upsert=True, rollback=True, return_messages=True),
+        ]
+
+    def test_upsert_data_retries_on_exception(self, mocker):
+        mock_fl = mocker.Mock()
+        mock_fl.append.side_effect = [Exception, (True, {'recordCount': 42})]
+        mocker.patch('palletjack.utils.Chunking.build_upload_json', autospec=True, return_value=['json'])
+        mocker.patch('palletjack.utils.sleep')
+
+        load.FeatureServiceUpdater._upsert_data(mocker.Mock(), mock_fl, mocker.Mock(), upsert=True)
+
+        assert mock_fl.append.call_count == 2
+
+    def test_upsert_data_raises_on_False_result(self, mocker):
+        mock_fl = mocker.Mock()
+        mock_fl.append.return_value = (False, {'message': 'foo'})
+        mocker.patch('palletjack.utils.Chunking.build_upload_json', autospec=True, return_value=['json'])
+        mocker.patch('palletjack.utils.sleep')
+
+        with pytest.raises(RuntimeError) as exc_info:
+            load.FeatureServiceUpdater._upsert_data(mocker.Mock(), mock_fl, mocker.Mock(), upsert=True)
+
+        assert exc_info.value.args[
+            0] == 'Failed to append data at chunk 1 of 1. Append operation should have been rolled back.'
+
+    def test_upsert_data_raises_on_upsert_field_not_in_append_fields(self, mocker):
+        append_kwargs = {'upsert_matching_field': 'foo', 'append_fields': ['bar', 'baz'], 'upsert': True}
+        with pytest.raises(ValueError) as exc_info:
+            load.FeatureServiceUpdater._upsert_data(mocker.Mock(), mocker.Mock(), mocker.Mock(), **append_kwargs)
+
+        assert exc_info.value.args[0
+                                  ] == 'Upsert matching field foo not found in either append fields or existing fields.'
+
+    def test_upsert_data_raises_on_upsert_field_not_in_dataframe_columns(self, mocker):
+        append_kwargs = {'upsert_matching_field': 'foo', 'append_fields': ['foo', 'bar'], 'upsert': True}
+        df = pd.DataFrame(columns=['bar', 'baz'])
+        with pytest.raises(ValueError) as exc_info:
+            load.FeatureServiceUpdater._upsert_data(mocker.Mock(), mocker.Mock(), df, **append_kwargs)
+
+        assert exc_info.value.args[0
+                                  ] == 'Upsert matching field foo not found in either append fields or existing fields.'
+
+    def test_upsert_data_raises_on_upsert_field_not_in_dataframe_columns_and_append_fields(self, mocker):
+        append_kwargs = {'upsert_matching_field': 'foo', 'append_fields': ['bar', 'baz'], 'upsert': True}
+        df = pd.DataFrame(columns=['bar', 'baz'])
+        with pytest.raises(ValueError) as exc_info:
+            load.FeatureServiceUpdater._upsert_data(mocker.Mock(), mocker.Mock(), df, **append_kwargs)
+
+        assert exc_info.value.args[0
+                                  ] == 'Upsert matching field foo not found in either append fields or existing fields.'
+
+
+class TestColorRampReclassifier:
+
+    def test_get_layer_id_returns_match_single_layer(self, mocker):
+        layers = {
+            'operationalLayers': [
+                {
+                    'title': 'foo'
+                },
+            ],
+        }
+        get_data_mock = mocker.Mock(return_value=layers)
+        webmap_item_mock = mocker.Mock()
+        webmap_item_mock.get_data = get_data_mock
+        reclassifier = load.ColorRampReclassifier(webmap_item_mock, 'gis')
+
+        layer_id = reclassifier._get_layer_id('foo')
+        assert layer_id == 0
+
+    def test_get_layer_id_returns_match_many_layers(self, mocker):
+        layers = {
+            'operationalLayers': [
+                {
+                    'title': 'foo'
+                },
+                {
+                    'title': 'bar'
+                },
+                {
+                    'title': 'baz'
+                },
+            ],
+        }
+        get_data_mock = mocker.Mock(return_value=layers)
+        webmap_item_mock = mocker.Mock()
+        webmap_item_mock.get_data = get_data_mock
+        reclassifier = load.ColorRampReclassifier(webmap_item_mock, 'gis')
+
+        layer_id = reclassifier._get_layer_id('bar')
+        assert layer_id == 1
+
+    def test_get_layer_id_returns_first_match(self, mocker):
+        layers = {
+            'operationalLayers': [
+                {
+                    'title': 'foo'
+                },
+                {
+                    'title': 'bar'
+                },
+                {
+                    'title': 'bar'
+                },
+            ],
+        }
+        get_data_mock = mocker.Mock(return_value=layers)
+        webmap_item_mock = mocker.Mock()
+        webmap_item_mock.get_data = get_data_mock
+        reclassifier = load.ColorRampReclassifier(webmap_item_mock, 'gis')
+
+        layer_id = reclassifier._get_layer_id('bar')
+        assert layer_id == 1
+
+    def test_get_layer_id_raises_error_when_not_found(self, mocker):
+        layers = {
+            'operationalLayers': [
+                {
+                    'title': 'bar'
+                },
+            ],
+        }
+        get_data_mock = mocker.Mock(return_value=layers)
+        webmap_item_mock = mocker.Mock()
+        webmap_item_mock.title = 'test map'
+        webmap_item_mock.get_data = get_data_mock
+        reclassifier = load.ColorRampReclassifier(webmap_item_mock, 'gis')
+
+        with pytest.raises(ValueError) as error_info:
+            layer_id = reclassifier._get_layer_id('foo')
+
+        assert 'Could not find "foo" in test map' in str(error_info.value)
+
+    def test_calculate_new_stops_with_manual_numbers(self):
+        dataframe = pd.DataFrame({'numbers': [100, 300, 500, 700, 900]})
+
+        stops = load.ColorRampReclassifier._calculate_new_stops(dataframe, 'numbers', 5)
+
+        assert stops == [100, 279, 458, 637, 816]
+
+    def test_calculate_new_stops_mismatched_column_raises_error(self):
+        dataframe = pd.DataFrame({'numbers': [100, 300, 500, 700, 900]})
+
+        with pytest.raises(ValueError) as error_info:
+            stops = load.ColorRampReclassifier._calculate_new_stops(dataframe, 'foo', 5)
+            assert 'Column `foo` not in dataframe`' in str(error_info)
+
+    def test_update_stops_values(self, mocker):
+        # renderer = data['operationalLayers'][layer_number]['layerDefinition']['drawingInfo']['renderer']
+        # stops = renderer['visualVariables'][0]['stops']
+
+        data = {
+            'operationalLayers': [{
+                'layerDefinition': {
+                    'drawingInfo': {
+                        'renderer': {
+                            'visualVariables': [{
+                                'stops': [{
+                                    'value': 0
+                                }, {
+                                    'value': 1
+                                }, {
+                                    'value': 2
+                                }, {
+                                    'value': 3
+                                }]
+                            }]
+                        }
+                    }
+                }
+            }],
+        }
+        get_data_mock = mocker.Mock(return_value=data)
+        webmap_item_mock = mocker.Mock()
+        webmap_item_mock.get_data = get_data_mock
+        update_mock = mocker.Mock()
+        webmap_item_mock.update = update_mock
+        reclassifier = load.ColorRampReclassifier(webmap_item_mock, 'gis')
+
+        reclassifier._update_stop_values(0, [100, 200, 300, 400])
+
+        data['operationalLayers'][0]['layerDefinition']['drawingInfo']['renderer']['visualVariables'][0]['stops'] = [{
+            'value': 100
+        }, {
+            'value': 200
+        }, {
+            'value': 300
+        }, {
+            'value': 400
+        }]
+
+        assert update_mock.called_with(item_properties={'text': json.dumps(data)})
diff --git a/tests/test_transform.py b/tests/test_transform.py
index a639ebe..9cfe925 100644
--- a/tests/test_transform.py
+++ b/tests/test_transform.py
@@ -10,13 +10,15 @@
 class TestAPIGeocoder:
 
     def test_geocode_dataframe_calls_with_right_args(self, mocker):
-        mocker.patch.object(palletjack.transform, 'utils', autospec=True)
-        palletjack.transform.utils.rename_columns_for_agol.return_value = {}
-        palletjack.transform.utils.geocode_addr.side_effect = [
+
+        utils_mock = mocker.patch('palletjack.transform.utils', autospec=True)
+        utils_mock.Geocoding.validate_api_key.return_value = 'valid'
+        utils_mock.rename_columns_for_agol.return_value = {}
+        utils_mock.Geocoding.geocode_addr.side_effect = [
             (123, 456, 100., 'foo_addr'),
             (789, 101, 100., 'bar_addr'),
         ]
-        mocker.patch.object(pd.DataFrame.spatial, 'from_xy')
+        mocker.patch('palletjack.transform.pd.DataFrame.spatial')
 
         geocoder = palletjack.transform.APIGeocoder('foo')
 
@@ -27,19 +29,20 @@ def test_geocode_dataframe_calls_with_right_args(self, mocker):
 
         geocoder.geocode_dataframe(test_df, 'street', 'zip', 3857)
 
-        assert palletjack.transform.utils.geocode_addr.call_args.args == (
+        assert utils_mock.Geocoding.geocode_addr.call_args.args == (
             '4501 S Constitution Blvd', '84129', 'foo', (0.015, 0.03)
         )
-        assert palletjack.transform.utils.geocode_addr.call_args.kwargs == {'spatialReference': '3857'}
+        assert utils_mock.Geocoding.geocode_addr.call_args.kwargs == {'spatialReference': '3857'}
 
     def test_geocode_dataframe_passes_kwargs_through_to_util_method(self, mocker):
-        mocker.patch.object(palletjack.transform, 'utils', autospec=True)
-        palletjack.transform.utils.rename_columns_for_agol.return_value = {}
-        palletjack.transform.utils.geocode_addr.side_effect = [
+        utils_mock = mocker.patch('palletjack.transform.utils', autospec=True)
+        utils_mock.Geocoding.validate_api_key.return_value = 'valid'
+        utils_mock.rename_columns_for_agol.return_value = {}
+        utils_mock.Geocoding.geocode_addr.side_effect = [
             (123, 456, 100., 'foo_addr'),
             (789, 101, 100., 'bar_addr'),
         ]
-        mocker.patch.object(pd.DataFrame.spatial, 'from_xy')
+        mocker.patch('palletjack.transform.pd.DataFrame.spatial')
 
         geocoder = palletjack.transform.APIGeocoder('foo')
 
@@ -50,18 +53,16 @@ def test_geocode_dataframe_passes_kwargs_through_to_util_method(self, mocker):
 
         geocoder.geocode_dataframe(test_df, 'street', 'zip', 3857, acceptScore=80)
 
-        assert palletjack.transform.utils.geocode_addr.call_args.args == (
+        assert utils_mock.Geocoding.geocode_addr.call_args.args == (
             '4501 S Constitution Blvd', '84129', 'foo', (0.015, 0.03)
         )
-        assert palletjack.transform.utils.geocode_addr.call_args.kwargs == {
-            'spatialReference': '3857',
-            'acceptScore': 80
-        }
+        assert utils_mock.Geocoding.geocode_addr.call_args.kwargs == {'spatialReference': '3857', 'acceptScore': 80}
 
     def test_geocode_dataframe_builds_output_dataframe(self, mocker, caplog):
         caplog.set_level(logging.DEBUG)
-        mocker.patch('palletjack.utils.requests', autospec=True)
+        requests_mock = mocker.patch('palletjack.utils.requests', autospec=True)
         mocker.patch('palletjack.utils.sleep')
+        mocker.patch('palletjack.utils.Geocoding.validate_api_key', return_value='valid')
 
         first_response = mocker.Mock()
         first_response.json.return_value = {
@@ -91,16 +92,9 @@ def test_geocode_dataframe_builds_output_dataframe(self, mocker, caplog):
         }
         second_response.status_code = 200
 
-        palletjack.utils.requests.get.side_effect = [first_response, second_response]
-
-        #: Get patch from_xy to just return the dataframe it was passed so we don't have to create a spatial one for
-        #: testing
-        mocker.patch.object(pd.DataFrame.spatial, 'from_xy')
+        requests_mock.get.side_effect = [first_response, second_response]
 
-        def _mock_from_xy(dataframe, x, y, sr=None):
-            return dataframe
-
-        pd.DataFrame.spatial.from_xy.side_effect = _mock_from_xy
+        mocker.patch('palletjack.transform.pd.DataFrame.spatial.from_xy', side_effect=lambda df, x, y, sr: df)
 
         geocoder = palletjack.transform.APIGeocoder('foo')
 
@@ -124,8 +118,9 @@ def _mock_from_xy(dataframe, x, y, sr=None):
 
     def test_geocode_dataframe_continues_after_failed_row(self, mocker, caplog):
         caplog.set_level(logging.DEBUG)
-        mocker.patch('palletjack.utils.requests', autospec=True)
+        requests_mock = mocker.patch('palletjack.utils.requests', autospec=True)
         mocker.patch('palletjack.utils.sleep')
+        mocker.patch('palletjack.utils.Geocoding.validate_api_key', return_value='valid')
 
         bad_response = mocker.Mock()
         bad_response.status_code = 500
@@ -144,18 +139,9 @@ def test_geocode_dataframe_continues_after_failed_row(self, mocker, caplog):
         }
         good_response.status_code = 200
 
-        palletjack.utils.requests.get.side_effect = [
-            bad_response, bad_response, bad_response, bad_response, good_response
-        ]
-
-        #: Get patch from_xy to just return the dataframe it was passed so we don't have to create a spatial one for
-        #: testing
-        mocker.patch.object(pd.DataFrame.spatial, 'from_xy')
+        requests_mock.get.side_effect = [bad_response, bad_response, bad_response, bad_response, good_response]
 
-        def _mock_from_xy(dataframe, x, y, sr=None):
-            return dataframe
-
-        pd.DataFrame.spatial.from_xy.side_effect = _mock_from_xy
+        mocker.patch('palletjack.transform.pd.DataFrame.spatial.from_xy', side_effect=lambda df, x, y, sr: df)
 
         geocoder = palletjack.transform.APIGeocoder('foo')
 
@@ -181,12 +167,9 @@ def _mock_from_xy(dataframe, x, y, sr=None):
         assert palletjack.utils.sleep.call_count == 5
 
     def test_geocode_dataframe_warns_on_empty_input(self, mocker):
-        mocker.patch.object(palletjack.transform, 'utils', autospec=True)
-        # palletjack.transform.utils.geocode_addr.side_effect = [
-        #     (123, 456, 100., 'foo_addr'),
-        #     (789, 101, 100., 'bar_addr'),
-        # ]
-        mocker.patch.object(pd.DataFrame.spatial, 'from_xy')
+        utils_mock = mocker.patch('palletjack.transform.utils', autospec=True)
+        utils_mock.Geocoding.validate_api_key.return_value = 'valid'
+        mocker.patch('palletjack.transform.pd.DataFrame.spatial.from_xy')
 
         geocoder = palletjack.transform.APIGeocoder('foo')
 
@@ -197,15 +180,15 @@ def test_geocode_dataframe_warns_on_empty_input(self, mocker):
         assert record[0].message.args[0] == 'No records to geocode (empty dataframe)'
 
     def test_geocode_dataframe_warns_on_empty_output(self, mocker):
-        mocker.patch.object(palletjack.transform, 'utils', autospec=True)
-        palletjack.transform.utils.rename_columns_for_agol.return_value = {}
-        palletjack.transform.utils.geocode_addr.side_effect = [
+        utils_mock = mocker.patch('palletjack.transform.utils', autospec=True)
+        utils_mock.Geocoding.validate_api_key.return_value = 'valid'
+        utils_mock.rename_columns_for_agol.return_value = {}
+        utils_mock.Geocoding.geocode_addr.side_effect = [
             (123, 456, 100., 'foo_addr'),
             (789, 101, 100., 'bar_addr'),
         ]
-        mocker.patch.object(
-            pd.DataFrame.spatial,
-            'from_xy',
+        mocker.patch(
+            'palletjack.transform.pd.DataFrame.spatial.from_xy',
             return_value=pd.DataFrame(columns=['street', 'zip', 'x', 'y', 'score', 'matchAddress'])
         )
 
@@ -221,7 +204,8 @@ def test_geocode_dataframe_warns_on_empty_output(self, mocker):
         assert record[0].message.args[0] == 'Empty spatial dataframe after geocoding'
 
     def test_geocode_dataframe_handles_weird_column_names(self, mocker):
-        mocker.patch('palletjack.utils.requests', autospec=True)
+        requests_mock = mocker.patch('palletjack.utils.requests', autospec=True)
+        mocker.patch('palletjack.utils.Geocoding.validate_api_key', return_value='valid')
         mocker.patch('palletjack.utils.sleep')
 
         good_response = mocker.Mock()
@@ -237,16 +221,10 @@ def test_geocode_dataframe_handles_weird_column_names(self, mocker):
             }
         }
         good_response.status_code = 200
-        palletjack.utils.requests.get.return_value = good_response
-
-        #: Get patch from_xy to just return the dataframe it was passed so we don't have to create a spatial one for
-        #: testing
-        mocker.patch.object(pd.DataFrame.spatial, 'from_xy')
+        requests_mock.get.return_value = good_response
 
-        def _mock_from_xy(dataframe, x, y, sr=None):
-            return dataframe
+        mocker.patch('palletjack.transform.pd.DataFrame.spatial.from_xy', side_effect=lambda df, x, y, sr: df)
 
-        pd.DataFrame.spatial.from_xy.side_effect = _mock_from_xy
         geocoder = palletjack.transform.APIGeocoder('foo')
         input_df = pd.DataFrame({
             'street': ['4315 S 2700 W'],
@@ -269,3 +247,141 @@ def _mock_from_xy(dataframe, x, y, sr=None):
         ]
 
         assert list(geocoded_df.columns) == out_cols
+
+    def test_geocode_dataframe_raises_on_invalid_key(self, mocker):
+        req_mock = mocker.patch('palletjack.utils.requests', autospec=True)
+        response_mock = mocker.Mock()
+        response_mock.json.return_value = {'status': 400, 'message': 'Invalid API key'}
+        req_mock.get.return_value = response_mock
+
+        with pytest.raises(ValueError):
+            palletjack.transform.APIGeocoder('foo')
+
+    # : TODO: test api validate responses in __init__
+
+
+class TestFeatureServiceMerging:
+
+    def test_update_live_data_with_new_data_works_normally(self):
+        new_dataframe = pd.DataFrame({
+            'col1': [10, 20, 30],
+            'col2': [40, 50, 60],
+            'key': ['a', 'b', 'c'],
+        })
+        live_dataframe = pd.DataFrame({
+            'col1': [1, 2, 3],
+            'col2': [4, 5, 6],
+            'key': ['a', 'b', 'c'],
+        })
+
+        joined = palletjack.transform.FeatureServiceMerging.update_live_data_with_new_data(
+            live_dataframe, new_dataframe, 'key'
+        )
+
+        expected = pd.DataFrame({
+            'key': ['a', 'b', 'c'],
+            'col1': [10, 20, 30],
+            'col2': [40, 50, 60],
+        }).convert_dtypes()
+
+        pd.testing.assert_frame_equal(joined, expected, check_like=True)
+
+    def test_update_live_data_with_new_data_only_updates_common_rows(self):
+
+        new_dataframe = pd.DataFrame({
+            'col1': [20, 30],
+            'col2': [50, 60],
+            'key': ['b', 'c'],
+        })
+        live_dataframe = pd.DataFrame({
+            'col1': [1, 2, 3],
+            'col2': [4, 5, 6],
+            'key': ['a', 'b', 'c'],
+        })
+
+        joined = palletjack.transform.FeatureServiceMerging.update_live_data_with_new_data(
+            live_dataframe, new_dataframe, 'key'
+        )
+
+        expected = pd.DataFrame({
+            'col1': [1, 20, 30],
+            'col2': [4, 50, 60],
+            'key': ['a', 'b', 'c'],
+        }).convert_dtypes()
+
+        pd.testing.assert_frame_equal(joined, expected, check_like=True)
+
+    def test_update_live_data_with_new_data_warns_on_missing_keys_and_handles_ints(self):
+        new_dataframe = pd.DataFrame({
+            'col1': [10, 20, 30, 80],
+            'col2': [40, 50, 60, 70],
+            'key': ['a', 'b', 'c', 'd'],
+        })
+        live_dataframe = pd.DataFrame({
+            'col1': [1, 2, 3],
+            'col2': [4, 5, 6],
+            'key': ['a', 'b', 'c'],
+        })
+
+        with pytest.warns(RuntimeWarning) as warning:
+            joined = palletjack.transform.FeatureServiceMerging.update_live_data_with_new_data(
+                live_dataframe, new_dataframe, 'key'
+            )
+
+        expected = pd.DataFrame({
+            'key': ['a', 'b', 'c'],
+            'col1': [10, 20, 30],
+            'col2': [40, 50, 60],
+        }).convert_dtypes()
+
+        pd.testing.assert_frame_equal(joined, expected, check_like=True)
+        assert 'The following keys from the new data were not found in the existing dataset: [\'d\']' in \
+            warning[0].message.args[0]
+
+    def test_get_live_dataframe_calls_each_method(self, mocker):
+        mock_arcgis = mocker.patch('palletjack.transform.arcgis')
+        mock_gis = mocker.Mock()
+
+        palletjack.transform.FeatureServiceMerging.get_live_dataframe(mock_gis, 'itemid', 42)
+
+        mock_gis.content.get.assert_called_once_with('itemid')
+        mock_arcgis.features.FeatureLayer.fromitem.assert_called_once_with(
+            mock_gis.content.get.return_value, layer_id=42
+        )
+        mock_arcgis.features.FeatureLayer.fromitem.return_value.query.assert_called_once_with(as_df=True)
+
+    def test_get_live_dataframe_raises_error_on_get_error(self, mocker):
+        mock_arcgis = mocker.patch('palletjack.transform.arcgis')
+        mock_gis = mocker.Mock()
+        mock_gis.content.get.side_effect = ValueError('get error')
+
+        with pytest.raises(RuntimeError) as error:
+            palletjack.transform.FeatureServiceMerging.get_live_dataframe(mock_gis, 'itemid')
+
+        assert 'Failed to load live dataframe' in str(error.value)
+        assert isinstance(error.value.__cause__, ValueError)
+        assert error.value.__cause__.args[0] == 'get error'
+
+    def test_get_live_dataframe_raises_error_on_fromitem_error(self, mocker):
+        mock_arcgis = mocker.patch('palletjack.transform.arcgis')
+        mock_gis = mocker.Mock()
+        mock_arcgis.features.FeatureLayer.fromitem.side_effect = ValueError('fromitem error')
+
+        with pytest.raises(RuntimeError) as error:
+            palletjack.transform.FeatureServiceMerging.get_live_dataframe(mock_gis, 'itemid')
+
+        assert 'Failed to load live dataframe' in str(error.value)
+        assert isinstance(error.value.__cause__, ValueError)
+        assert error.value.__cause__.args[0] == 'fromitem error'
+
+    def test_get_live_dataframe_raises_error_on_query_error(self, mocker):
+        mock_arcgis = mocker.patch('palletjack.transform.arcgis')
+        mock_gis = mocker.Mock()
+        mock_arcgis.features.FeatureLayer.fromitem.return_value.query.side_effect = ValueError('query error')
+
+        with pytest.raises(RuntimeError) as error:
+            palletjack.transform.FeatureServiceMerging.get_live_dataframe(mock_gis, 'itemid')
+
+        assert 'Failed to load live dataframe' in str(error.value)
+        assert isinstance(error.value.__cause__, ValueError)
+        assert error.value.__cause__.args[0] == 'query error'
diff --git a/tests/test_utils.py b/tests/test_utils.py
index dae344e..18092cb 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,13 +1,23 @@
+import datetime
 import logging
+import re
+from collections import namedtuple
+from pathlib import Path
 
 import numpy as np
 import pandas as pd
 import pytest
+from arcgis import geometry
 from pandas import testing as tm
 
 import palletjack
 
 
+@pytest.fixture(scope='module')  #: only call this once per module
+def iris():
+    return pd.read_csv('https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv')
+
+
 class TestRenameColumns:
 
     def test_rename_columns_for_agol_handles_special_and_space(self):
@@ -64,32 +74,28 @@ class TestCheckFieldsMatch:
 
     def test_check_fields_match_normal(self, mocker):
         mock_fl = mocker.Mock()
-        mock_fl.properties = {
-            'fields': [
-                {
-                    'name': 'Foo'
-                },
-                {
-                    'name': 'Bar'
-                },
-            ]
-        }
+        mock_fl.properties.fields = [
+            {
+                'name': 'Foo'
+            },
+            {
+                'name': 'Bar'
+            },
+        ]
         df = pd.DataFrame(columns=['Foo', 'Bar'])
 
         palletjack.utils.check_fields_match(mock_fl, df)
 
     def test_check_fields_match_raises_error_on_extra_new_field(self, mocker):
         mock_fl = mocker.Mock()
-        mock_fl.properties = {
-            'fields': [
-                {
-                    'name': 'Foo'
-                },
-                {
-                    'name': 'Bar'
-                },
-            ]
-        }
+        mock_fl.properties.fields = [
+            {
+                'name': 'Foo'
+            },
+            {
+                'name': 'Bar'
+            },
+        ]
         df = pd.DataFrame(columns=['Foo', 'Bar', 'Baz'])
 
         with pytest.raises(RuntimeError) as exc_info:
@@ -100,35 +106,31 @@ def test_check_fields_match_raises_error_on_extra_new_field(self, mocker):
 
     def test_check_fields_match_ignores_new_shape_field(self, mocker):
         mock_fl = mocker.Mock()
-        mock_fl.properties = {
-            'fields': [
-                {
-                    'name': 'Foo'
-                },
-                {
-                    'name': 'Bar'
-                },
-            ]
-        }
+        mock_fl.properties.fields = [
+            {
+                'name': 'Foo'
+            },
+            {
+                'name': 'Bar'
+            },
+        ]
         df = pd.DataFrame(columns=['Foo', 'Bar', 'SHAPE'])
 
         palletjack.utils.check_fields_match(mock_fl, df)
 
     def test_check_fields_match_warns_on_missing_new_field(self, mocker, caplog):
         mock_fl = mocker.Mock()
-        mock_fl.properties = {
-            'fields': [
-                {
-                    'name': 'Foo'
-                },
-                {
-                    'name': 'Bar'
-                },
-                {
-                    'name': 'Baz'
-                },
-            ]
-        }
+        mock_fl.properties.fields = [
+            {
+                'name': 'Foo'
+            },
+            {
+                'name': 'Bar'
+            },
+            {
+                'name': 'Baz'
+            },
+        ]
         df = pd.DataFrame(columns=['Foo', 'Bar'])
 
         palletjack.utils.check_fields_match(mock_fl, df)
@@ -189,83 +191,31 @@ def test_retry_fails_after_four_failures(self, mocker):
         assert mock.function.call_count == 4
 
 
-class TestReplaceNanSeries:
-
-    def test_replace_nan_series_with_empty_strings_one_empty_one_non_empty_float(self, mocker):
-        df = pd.DataFrame({
-            'normal': [1., 2., 3.],
-            'empty': [np.nan, np.nan, np.nan],
-        })
-
-        fixed_df = palletjack.utils.replace_nan_series_with_empty_strings(df)
-
-        test_df = pd.DataFrame({
-            'normal': [1., 2., 3.],
-            'empty': ['', '', ''],
-        })
-
-        tm.assert_frame_equal(fixed_df, test_df)
-
-    def test_replace_nan_series_with_empty_strings_other_series_has_nan(self, mocker):
-        df = pd.DataFrame({
-            'normal': [1., 2., np.nan],
-            'empty': [np.nan, np.nan, np.nan],
-        })
-
-        fixed_df = palletjack.utils.replace_nan_series_with_empty_strings(df)
-
-        test_df = pd.DataFrame({
-            'normal': [1., 2., np.nan],
-            'empty': ['', '', ''],
-        })
-
-        tm.assert_frame_equal(fixed_df, test_df)
-
-    def test_replace_nan_series_with_empty_strings_other_series_is_empty_str(self, mocker):
-        df = pd.DataFrame({
-            'normal': ['', '', ''],
-            'empty': [np.nan, np.nan, np.nan],
-        })
-
-        fixed_df = palletjack.utils.replace_nan_series_with_empty_strings(df)
-
-        test_df = pd.DataFrame({
-            'normal': ['', '', ''],
-            'empty': ['', '', ''],
-        })
-
-        tm.assert_frame_equal(fixed_df, test_df)
-
-
 class TestCheckIndexColumnInFL:
 
     def test_check_index_column_in_feature_layer_doesnt_raise_on_normal(self, mocker):
         fl_mock = mocker.Mock()
-        fl_mock.properties = {
-            'fields': [
-                {
-                    'name': 'Foo'
-                },
-                {
-                    'name': 'Bar'
-                },
-            ]
-        }
+        fl_mock.properties.fields = [
+            {
+                'name': 'Foo'
+            },
+            {
+                'name': 'Bar'
+            },
+        ]
 
         palletjack.utils.check_index_column_in_feature_layer(fl_mock, 'Foo')
 
     def test_check_index_column_in_feature_layer_raises_on_missing(self, mocker):
         fl_mock = mocker.Mock()
-        fl_mock.properties = {
-            'fields': [
-                {
-                    'name': 'Foo'
-                },
-                {
-                    'name': 'Bar'
-                },
-            ]
-        }
+        fl_mock.properties.fields = [
+            {
+                'name': 'Foo'
+            },
+            {
+                'name': 'Bar'
+            },
+        ]
 
         with pytest.raises(RuntimeError) as exc_info:
             palletjack.utils.check_index_column_in_feature_layer(fl_mock, 'Baz')
@@ -277,27 +227,23 @@ class TestCheckFieldUnique:
 
     def test_check_field_set_to_unique_doesnt_raise_on_normal(self, mocker):
         mock_fl = mocker.Mock()
-        mock_fl.properties = {
-            'indexes': [
-                {
-                    'fields': 'foo_field',
-                    'isUnique': True
-                },
-            ]
-        }
+        mock_fl.properties.indexes = [
+            {
+                'fields': 'foo_field',
+                'isUnique': True
+            },
+        ]
 
         palletjack.utils.check_field_set_to_unique(mock_fl, 'foo_field')
 
     def test_check_field_set_to_unique_raises_on_non_indexed_field(self, mocker):
         mock_fl = mocker.Mock()
-        mock_fl.properties = {
-            'indexes': [
-                {
-                    'fields': 'bar_field',
-                    'isUnique': True
-                },
-            ]
-        }
+        mock_fl.properties.indexes = [
+            {
+                'fields': 'bar_field',
+                'isUnique': True
+            },
+        ]
 
         with pytest.raises(RuntimeError) as exc_info:
             palletjack.utils.check_field_set_to_unique(mock_fl, 'foo_field')
@@ -306,14 +252,12 @@ def test_check_field_set_to_unique_raises_on_non_indexed_field(self, mocker):
 
     def test_check_field_set_to_unique_raises_on_non_unique_field(self, mocker):
         mock_fl = mocker.Mock()
-        mock_fl.properties = {
-            'indexes': [
-                {
-                    'fields': 'foo_field',
-                    'isUnique': False
-                },
-            ]
-        }
+        mock_fl.properties.indexes = [
+            {
+                'fields': 'foo_field',
+                'isUnique': False
+            },
+        ]
 
         with pytest.raises(RuntimeError) as exc_info:
             palletjack.utils.check_field_set_to_unique(mock_fl, 'foo_field')
@@ -343,7 +287,7 @@ def test_geocode_addr_builds_url_correctly(self, mocker):
 
         palletjack.utils.requests.get.return_value = response_mock
 
-        palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
+        palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
 
         palletjack.utils.requests.get.assert_called_with(
             'https://api.mapserv.utah.gov/api/v1/geocode/foo/bar', params={'apiKey': 'foo_key'}
@@ -369,7 +313,7 @@ def test_geocode_addr_handles_kwargs_for_geocoding_api(self, mocker):
 
         palletjack.utils.requests.get.return_value = response_mock
 
-        palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03), spatialReference=3857)
+        palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03), spatialReference=3857)
 
         palletjack.utils.requests.get.assert_called_with(
             'https://api.mapserv.utah.gov/api/v1/geocode/foo/bar',
@@ -399,7 +343,7 @@ def test_geocode_addr_returns_coords(self, mocker):
 
         palletjack.utils.requests.get.return_value = response_mock
 
-        result = palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
+        result = palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
 
         assert result == (123, 456, 100., 'bar')
 
@@ -412,7 +356,7 @@ def test_geocode_addr_returns_null_island_bad_status_code(self, mocker):
 
         palletjack.utils.requests.get.return_value = response_mock
 
-        result = palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
+        result = palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
 
         assert result == (0, 0, 0., 'No Match')
 
@@ -432,7 +376,7 @@ def bool_mock(self):
 
         palletjack.utils.requests.get.return_value = response_mock
 
-        result = palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
+        result = palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
 
         assert result == (0, 0, 0., 'No Match')
 
@@ -452,7 +396,7 @@ def bool_mock(self):
 
         palletjack.utils.requests.get.return_value = response_mock
 
-        result = palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
+        result = palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
 
         assert 'No response from GET; request timeout?' not in caplog.text
 
@@ -476,7 +420,7 @@ def test_geocode_addr_retries_on_exception(self, mocker):
 
         palletjack.utils.requests.get.side_effect = [Exception, response_mock]
 
-        result = palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
+        result = palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
 
         assert palletjack.utils.requests.get.call_count == 2
         assert result == (123, 456, 100., 'bar')
@@ -502,7 +446,7 @@ def test_geocode_addr_retries_on_missing_response(self, mocker, caplog):
 
         palletjack.utils.requests.get.side_effect = [None, response_mock]
 
-        result = palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
+        result = palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
 
         assert 'No response from GET; request timeout?' in caplog.text
         assert palletjack.utils.requests.get.call_count == 2
@@ -532,7 +476,7 @@ def test_geocode_addr_retries_on_bad_status_code(self, mocker, caplog):
 
         palletjack.utils.requests.get.side_effect = [first_response_mock, second_response_mock]
 
-        result = palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
+        result = palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
 
         assert 'Did not receive a valid geocoding response; status code: 500' in caplog.text
         assert palletjack.utils.requests.get.call_count == 2
@@ -547,7 +491,7 @@ def test_geocode_addr_handles_complete_geocode_failure(self, mocker, caplog):
         bad_response.status_code = 500
         palletjack.utils.requests.get.side_effect = [bad_response] * 4
 
-        result = palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
+        result = palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
 
         assert 'Did not receive a valid geocoding response; status code: 500' in caplog.messages
         assert palletjack.utils.requests.get.call_count == 4
@@ -573,7 +517,7 @@ def test_geocode_addr_sleeps_appropriately(self, mocker):
 
         palletjack.utils.requests.get.return_value = response_mock
 
-        palletjack.utils.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
+        palletjack.utils.Geocoding.geocode_addr('foo', 'bar', 'foo_key', (0.015, 0.03))
 
         palletjack.utils.sleep.assert_called_once()
 
@@ -600,40 +544,46 @@ def test_calc_modulus_for_reporting_interval_handles_more_than_split_value(self)
 class TestValidateAPIKey:
 
     def test_validate_api_key_good_key(self, mocker):
-        mocker.patch('palletjack.utils.requests', autospec=True)
+        req_mock = mocker.patch('palletjack.utils.requests', autospec=True)
         response_mock = mocker.Mock()
         response_mock.json.return_value = {'status': 200, 'message': 'foo'}
-        palletjack.utils.requests.get.return_value = response_mock
+        req_mock.get.return_value = response_mock
 
-        check = palletjack.utils.validate_api_key('foo')
-
-        assert check == 'valid'
+        #: Should not raise
+        palletjack.utils.Geocoding.validate_api_key('foo')
 
     def test_validate_api_key_bad_key(self, mocker):
-        mocker.patch('palletjack.utils.requests', autospec=True)
+        req_mock = mocker.patch('palletjack.utils.requests', autospec=True)
         response_mock = mocker.Mock()
         response_mock.json.return_value = {'status': 400, 'message': 'Invalid API key'}
-        palletjack.utils.requests.get.return_value = response_mock
+        req_mock.get.return_value = response_mock
 
-        check = palletjack.utils.validate_api_key('foo')
+        with pytest.raises(ValueError, match=re.escape('API key validation failed: Invalid API key')):
+            palletjack.utils.Geocoding.validate_api_key('foo')
 
-        assert check == 'Invalid API key'
-
-    def test_validate_api_key_handles_exception(self, mocker, caplog):
-        mocker.patch('palletjack.utils.requests', autospec=True)
+    def test_validate_api_key_handles_network_exception(self, mocker, caplog):
+        req_mock = mocker.patch('palletjack.utils.requests', autospec=True)
         mocker.patch('palletjack.utils.sleep')
-        caplog.set_level(logging.DEBUG)
-        # response_mock = mocker.Mock()
-        # response_mock.json.return_value = {
-        #     'status': 400,
-        #     'message': 'foo'
-        # }
-        palletjack.utils.requests.get.side_effect = [Exception('Random Error')] * 4
-
-        check = palletjack.utils.validate_api_key('foo')
-        assert check == 'Could not determine key validity; check your API key and/or network connection'
-        assert palletjack.utils.requests.get.call_count == 4
-        assert 'Random Error' in caplog.messages
+        # caplog.set_level(logging.DEBUG)
+        req_mock.get.side_effect = [IOError('Random Error')] * 4
+
+        with pytest.raises(
+            RuntimeError,
+            match=re.escape('Could not determine key validity; check your API key and/or network connection')
+        ) as exc_info:
+            palletjack.utils.Geocoding.validate_api_key('foo')
+
+        assert req_mock.get.call_count == 4
+        assert 'Random Error' in str(exc_info.value.__cause__)
+
+    def test_validate_api_key_handles_other_response(self, mocker):
+        req_mock = mocker.patch('palletjack.utils.requests', autospec=True)
+        response_mock = mocker.Mock()
+        response_mock.json.return_value = {'status': 404, 'message': 'Weird Response'}
+        req_mock.get.return_value = response_mock
+
+        with pytest.warns(UserWarning, match=re.escape('Unhandled API key validation response 404: Weird Response')):
+            palletjack.utils.Geocoding.validate_api_key('foo')
 
 
 class TestFieldRenaming:
@@ -730,3 +680,974 @@ def test_authorize_pygsheets_raises_after_failing_both(self, mocker, caplog):
             mocker.call(service_file='credentials'),
             mocker.call(custom_credentials='credentials')
         ]
+
+
+class TestEmptyStringsAsNulls:
+
+    def test_converts_empty_strings_to_null(self):
+        FeatureSet = namedtuple('FeatureSet', ['features'])
+        Feature = namedtuple('Feature', ['attributes'])
+        feature_set = FeatureSet([Feature({
+            'a': 'foo',
+            'b': 'baz',
+        }), Feature({
+            'a': '',
+            'b': '',
+        })])
+        fields = [{
+            'name': 'a',
+            'type': 'esriFieldTypeInteger',
+            'nullable': False,
+        }, {
+            'name': 'b',
+            'type': 'esriFieldTypeInteger',
+            'nullable': True,
+        }]
+
+        fixed_feature_set = palletjack.utils.fix_numeric_empty_strings(feature_set, fields)
+        fixed_feature = fixed_feature_set.features[1]
+
+        assert fixed_feature.attributes['a'] == ''
+        assert fixed_feature.attributes['b'] is None
+
+    def test_fix_numeric_empty_strings_handles_both_missing_shape_info_fields(self):
+        FeatureSet = namedtuple('FeatureSet', ['features'])
+        Feature = namedtuple('Feature', ['attributes'])
+        feature_set = FeatureSet([Feature({
+            'a': 'foo',
+            'b': 'baz',
+        }), Feature({
+            'a': '',
+            'b': '',
+        })])
+        fields = [{
+            'name': 'a',
+            'type': 'esriFieldTypeInteger',
+            'nullable': False,
+        }, {
+            'name': 'b',
+            'type': 'esriFieldTypeInteger',
+            'nullable': True,
+        }, {
+            'name': 'Shape__Length',
+            'type': 'esriFieldTypeDouble',
+            'nullable': True,
+        }, {
+            'name': 'Shape__Area',
+            'type': 'esriFieldTypeDouble',
+            'nullable': True,
+        }]
+
+        fixed_feature_set = palletjack.utils.fix_numeric_empty_strings(feature_set, fields)
+        fixed_feature = fixed_feature_set.features[1]
+
+        assert fixed_feature.attributes['a'] == ''
+        assert fixed_feature.attributes['b'] is None
+
+    def test_fix_numeric_empty_strings_handles_single_missing_shape_info_field(self):
+        FeatureSet = namedtuple('FeatureSet', ['features'])
+        Feature = namedtuple('Feature', ['attributes'])
+        feature_set = FeatureSet([Feature({
+            'a': 'foo',
+            'b': 'baz',
+        }), Feature({
+            'a': '',
+            'b': '',
+        })])
+        fields = [{
+            'name': 'a',
+            'type': 'esriFieldTypeInteger',
+            'nullable': False,
+        }, {
+            'name': 'b',
+            'type': 'esriFieldTypeInteger',
+            'nullable': True,
+        }, {
+            'name': 'Shape__Length',
+            'type': 'esriFieldTypeDouble',
+            'nullable': True,
+        }]
+
+        fixed_feature_set = palletjack.utils.fix_numeric_empty_strings(feature_set, fields)
+        fixed_feature = fixed_feature_set.features[1]
+
+        assert fixed_feature.attributes['a'] == ''
+        assert fixed_feature.attributes['b'] is None
+
+
+class TestCheckFieldsMatch:
+
+    def test_check_live_and_new_field_types_match_normal(self, mocker):
+        new_df = pd.DataFrame({
+            'ints': [1, 2, 3],
+            'floats': [4., 5., 6.],
+            'strings': ['a', 'b', 'c'],
+            'OBJECTID': [11, 12, 13],
+            'GlobalID': [
+                'cc1cd617-1e55-4153-914d-8abb6ef22f24', '0f45d56f-249e-494a-863e-6b3999619bae',
+                'd3a64873-8a09-4351-9ea0-802e450329ea'
+            ],
+        })
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'OBJECTID',
+                'type': 'esriFieldTypeOID'
+            },
+            {
+                'name': 'strings',
+                'type': 'esriFieldTypeString'
+            },
+            {
+                'name': 'ints',
+                'type': 'esriFieldTypeInteger'
+            },
+            {
+                'name': 'floats',
+                'type': 'esriFieldTypeDouble'
+            },
+            {
+                'name': 'GlobalID',
+                'type': 'esriFieldTypeGlobalID'
+            },
+        ]
+
+        #: If it raises an error, it failed.
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+        checker.check_live_and_new_field_types_match(['ints', 'floats', 'strings', 'OBJECTID', 'GlobalID'])
+
+    def test_check_live_and_new_field_types_match_converted(self, mocker):
+        new_df = pd.DataFrame({
+            'ints': [1, 2, 3],
+            'floats': [4.1, 5.1, 6.1],
+            'strings': ['a', 'b', 'c'],
+            'OBJECTID': [11, 12, 13],
+            'GlobalID': [
+                'cc1cd617-1e55-4153-914d-8abb6ef22f24', '0f45d56f-249e-494a-863e-6b3999619bae',
+                'd3a64873-8a09-4351-9ea0-802e450329ea'
+            ],
+            # 'SHAPE': [geometry.Geometry([0, 0])] * 3
+        }).convert_dtypes()
+
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'OBJECTID',
+                'type': 'esriFieldTypeOID'
+            },
+            {
+                'name': 'strings',
+                'type': 'esriFieldTypeString'
+            },
+            {
+                'name': 'ints',
+                'type': 'esriFieldTypeInteger'
+            },
+            {
+                'name': 'floats',
+                'type': 'esriFieldTypeDouble'
+            },
+            {
+                'name': 'GlobalID',
+                'type': 'esriFieldTypeGlobalID'
+            },
+        ]
+
+        #: If it raises an error, it failed.
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+        checker.check_live_and_new_field_types_match(['ints', 'floats', 'strings', 'OBJECTID', 'GlobalID'])
+
+    def test_check_live_and_new_field_types_match_raises_on_incompatible(self, mocker):
+        new_df = pd.DataFrame({
+            'ints': [1, 2, 3],
+        })
+
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [{'name': 'ints', 'type': 'esriFieldTypeDouble'}]
+
+        with pytest.raises(
+            ValueError,
+            match=re.escape(
+                'Field type incompatibilities (field, live type, new type): [(\'ints\', \'esriFieldTypeDouble\', \'int64\')]'
+            )
+        ):
+            checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+            checker.check_live_and_new_field_types_match(['ints'])
+
+    def test_check_live_and_new_field_types_match_raises_on_multiple_incompatible(self, mocker):
+        new_df = pd.DataFrame({
+            'ints': [1, 2, 3],
+            'floats': [1.1, 1.2, 1.3],
+        })
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'ints',
+                'type': 'esriFieldTypeDouble'
+            },
+            {
+                'name': 'floats',
+                'type': 'esriFieldTypeInteger'
+            },
+        ]
+
+        with pytest.raises(
+            ValueError,
+            match=re.escape(
+                'Field type incompatibilities (field, live type, new type): [(\'ints\', \'esriFieldTypeDouble\', \'int64\'), (\'floats\', \'esriFieldTypeInteger\', \'float64\')]'
+            )
+        ):
+            checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+            checker.check_live_and_new_field_types_match(['ints', 'floats'])
+
+    def test_check_live_and_new_field_types_match_raises_on_notimplemented_esri_type(self, mocker):
+        new_df = pd.DataFrame({
+            'ints': [1, 2, 3],
+        })
+
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [{'name': 'ints', 'type': 'esriFieldTypeDate'}]
+
+        with pytest.raises(NotImplementedError) as exc_info:
+            checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+            checker.check_live_and_new_field_types_match(['ints'])
+
+        assert 'Live field "ints" type "esriFieldTypeDate" not yet mapped to a pandas dtype' in str(exc_info.value)
+
+    def test_check_live_and_new_field_types_removes_SHAPE(self, mocker):
+        geocheck_mock = mocker.patch('palletjack.utils.FieldChecker._check_geometry_types')
+        new_df = pd.DataFrame({
+            'ints': [1, 2, 3],
+            'SHAPE': [geometry.Geometry([0, 0])] * 3,
+        }).convert_dtypes()
+
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [{'name': 'ints', 'type': 'esriFieldTypeInteger'}]
+
+        #: If it raises an error, it failed.
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+        checker.check_live_and_new_field_types_match(['ints', 'SHAPE'])
+        geocheck_mock.assert_called_once()
+
+    def test_check_geometry_types_normal(self, mocker):
+        new_df = pd.DataFrame.spatial.from_xy(
+            pd.DataFrame({
+                'OBJECTID': [11, 12, 13],
+                'x': [0, 0, 0],
+                'y': [0, 0, 0],
+            }), x_column='x', y_column='y'
+        )
+
+        properties_mock = mocker.Mock()
+        properties_mock.geometryType = 'esriGeometryPoint'
+        properties_mock.fields = {'a': ['b']}
+
+        #: If it raises an error, it failed.
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+        checker._check_geometry_types()
+
+    def test_check_geometry_types_raises_on_multiple_types(self, mocker):
+        new_df = mocker.Mock()
+        new_df.columns = ['SHAPE']
+        new_df.spatial.geometry_type = [1, 2]
+
+        properties_mock = mocker.Mock()
+        properties_mock.geometryType = 'esriGeometryPoint'
+        properties_mock.fields = {'a': ['b']}
+
+        with pytest.raises(ValueError) as exc_info:
+            checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+            checker._check_geometry_types()
+
+        assert 'New dataframe has multiple geometry types' in str(exc_info.value)
+
+    def test_check_geometry_types_raises_on_incompatible_type(self, mocker):
+        new_df = mocker.Mock()
+        new_df.columns = ['SHAPE']
+        new_df.spatial.geometry_type = ['Polygon']
+
+        properties_mock = mocker.Mock()
+        properties_mock.geometryType = 'esriGeometryPoint'
+        properties_mock.fields = {'a': ['b']}
+
+        with pytest.raises(ValueError) as exc_info:
+            checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+            checker._check_geometry_types()
+
+        assert 'New dataframe geometry type "Polygon" incompatible with live geometry type "esriGeometryPoint"' in str(
+            exc_info.value
+        )
+
+    def test_check_geometry_types_raises_on_missing_SHAPE(self, mocker):
+        new_df = mocker.Mock()
+        new_df.columns = ['foo']
+
+        checker_mock = mocker.Mock()
+        checker_mock.new_dataframe = new_df
+
+        with pytest.raises(ValueError) as exc_info:
+            palletjack.utils.FieldChecker._check_geometry_types(checker_mock)
+
+        assert 'New dataframe does not have a SHAPE column' in str(exc_info.value)
+
+
+class TestFieldNullChecker:
+
+    def test_check_for_non_null_fields_raises_on_null_data_in_nonnullable_field(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'regular',
+                'nullable': True,
+                'defaultValue': None,
+            },
+            {
+                'name': 'non-nullable',
+                'nullable': False,
+                'defaultValue': None,
+            },
+        ]
+
+        new_df = pd.DataFrame({
+            'regular': ['a', 'b'],
+            'non-nullable': ['c', None],
+        })
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        with pytest.raises(ValueError) as exc_info:
+            checker.check_for_non_null_fields(['regular', 'non-nullable'])
+
+        assert 'The following fields cannot have null values in the live data but one or more nulls exist in the new data: non-nullable' in str(
+            exc_info.value
+        )
+
+    def test_check_for_non_null_fields_doesnt_raise_on_null_in_nullable_field(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'regular',
+                'nullable': True,
+                'defaultValue': None,
+            },
+        ]
+
+        new_df = pd.DataFrame({
+            'regular': ['a', None],
+        })
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: Should not raise an error
+        checker.check_for_non_null_fields(['regular'])
+
+    def test_check_for_non_null_fields_doesnt_raise_on_null_in_nonnullable_with_default(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'regular',
+                'nullable': True,
+                'defaultValue': 'foo',
+            },
+        ]
+
+        new_df = pd.DataFrame({
+            'regular': ['a', None],
+        })
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: Should not raise an error
+        checker.check_for_non_null_fields(['regular'])
+
+    def test_check_for_non_null_fields_skips_field(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'regular',
+                'nullable': True,
+                'defaultValue': None,
+            },
+            {
+                'name': 'non-nullable',
+                'nullable': False,
+                'defaultValue': None,
+            },
+        ]
+
+        new_df = pd.DataFrame({
+            'regular': ['a', 'b'],
+            'non-nullable': ['c', None],
+        })
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: Should not raise
+        checker.check_for_non_null_fields(['regular'])
+
+
+class TestFieldLength:
+
+    def test_check_field_length_normal_string(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'foo',
+                'type': 'esriFieldTypeString',
+                'length': 10,
+            },
+        ]
+
+        new_df = pd.DataFrame({
+            'foo': ['aaa', 'bbbb'],
+        })
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: Should not raise
+        checker.check_field_length(['foo'])
+
+    def test_check_field_length_raises_on_long_string(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'foo',
+                'type': 'esriFieldTypeString',
+                'length': 10,
+            },
+        ]
+
+        new_df = pd.DataFrame({
+            'foo': ['aaa', 'bbbb', 'this string is far too long'],
+        })
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        with pytest.raises(ValueError) as exc_info:
+            checker.check_field_length(['foo'])
+
+        assert 'Row 2, column foo in new data exceeds the live data max length of 10' in str(exc_info.value)
+
+    def test_check_field_length_uses_fields_arg(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'foo',
+                'type': 'esriFieldTypeString',
+                'length': 10,
+            },
+            {
+                'name': 'bar',
+                'type': 'esriFieldTypeString',
+                'length': 10,
+            },
+        ]
+        new_df = pd.DataFrame({
+            'foo': ['aaa', 'bbbb'],
+            'bar': ['a', 'way too long field'],
+        })
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: bar shouldn't trigger an exception
+        checker.check_field_length(['foo'])
+
+    def test_check_field_length_uses_ignores_new_field_not_in_live_data(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'foo',
+                'type': 'esriFieldTypeString',
+                'length': 10,
+            },
+        ]
+        new_df = pd.DataFrame({
+            'foo': ['aaa', 'bbbb'],
+            'bar': ['a', 'way too long field'],
+        })
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: bar shouldn't trigger an exception
+        checker.check_field_length(['foo'])
+
+    def test_check_field_length_works_with_int_field(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'foo',
+                'type': 'esriFieldTypeString',
+                'length': 10,
+            },
+            {
+                'name': 'int',
+                'type': 'esriFieldTypeInteger',
+            },
+        ]
+        new_df = pd.DataFrame({
+            'foo': ['aaa', 'bbbb'],
+            'int': [1, 2],
+        })
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: Should not raise
+        checker.check_field_length(['foo', 'int'])
+
+    def test_check_field_length_passes_with_only_int_fields(self, mocker, caplog):
+        caplog.set_level(logging.DEBUG)
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [
+            {
+                'name': 'foo',
+                'type': 'esriFieldTypeInteger',
+            },
+            {
+                'name': 'int',
+                'type': 'esriFieldTypeInteger',
+            },
+        ]
+        new_df = pd.DataFrame({
+            'foo': ['aaa', 'bbbb'],
+            'int': [1, 2],
+        })
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: Should not raise
+        checker.check_field_length(['foo'])
+
+        assert 'No fields with length property' in caplog.text
+
+
+class TestFieldsPresent:
+
+    def test_check_fields_present_normal_in_both(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [{'name': 'foo'}, {'name': 'bar'}]
+
+        new_df = pd.DataFrame(columns=['foo', 'bar'])
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: Should not raise
+        checker.check_fields_present(['foo', 'bar'], False)
+
+    def test_check_fields_present_raises_missing_live(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [{'name': 'foo'}]
+
+        new_df = pd.DataFrame(columns=['foo', 'bar'])
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        with pytest.raises(RuntimeError) as exc_info:
+            checker.check_fields_present(['foo', 'bar'], False)
+        assert 'Fields missing in live data: bar' in str(exc_info.value)
+
+    def test_check_fields_present_raises_missing_new(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [{'name': 'foo'}, {'name': 'bar'}]
+
+        new_df = pd.DataFrame(columns=['foo'])
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        with pytest.raises(RuntimeError) as exc_info:
+            checker.check_fields_present(['foo', 'bar'], False)
+        assert 'Fields missing in new data: bar' in str(exc_info.value)
+
+    def test_check_fields_present_raises_missing_both(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [{'name': 'foo'}]
+
+        new_df = pd.DataFrame(columns=['bar'])
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        with pytest.raises(RuntimeError) as exc_info:
+            checker.check_fields_present(['foo', 'bar'], False)
+        assert 'Fields missing in live data: bar. Fields missing in new data: foo' in str(exc_info.value)
+
+    def test_check_fields_present_adds_oid_to_list_of_fields_to_check(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = [{'name': 'foo'}, {'name': 'bar'}, {'name': 'OBJECTID'}]
+
+        new_df = pd.DataFrame(columns=['foo', 'bar', 'OBJECTID'])
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: Should not raise
+        checker.check_fields_present(['foo', 'bar'], True)
+
+
+class TestEmptyFieldWarnings:
+
+    def test_check_for_empty_float_fields_doesnt_raise_on_no_empty_fields(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = ['int, float']
+        new_df = pd.DataFrame({'int': [1, 2, 3], 'float': [1.1, 1.2, 1.3]})
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        #: Should not raise
+        checker.check_for_empty_float_fields(['int', 'float'])
+
+    def test_check_for_empty_float_fields_raises_on_single_field(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = ['int, float']
+        new_df = pd.DataFrame({'int': [1, 2, 3], 'float': [np.nan] * 3})
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        with pytest.raises(
+            ValueError,
+            match=re.escape(
+                'The following float/double column(s) are completely empty: [\'float\'] (suggestion: insert at least one bogus value)'
+            )
+        ):
+            checker.check_for_empty_float_fields(['int', 'float'])
+
+    def test_check_for_empty_float_fields_raises_on_multiple_fields(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.fields = ['foo, float']
+        new_df = pd.DataFrame({'foo': [np.nan] * 3, 'float': [np.nan] * 3})
+
+        checker = palletjack.utils.FieldChecker(properties_mock, new_df)
+
+        with pytest.raises(
+            ValueError,
+            match=re.escape(
+                'The following float/double column(s) are completely empty: [\'foo\', \'float\'] (suggestion: insert at least one bogus value)'
+            )
+        ):
+            checker.check_for_empty_float_fields(['foo', 'float'])
+
+
+class TestSRSCheck:
+
+    def test_check_srs_match_good_match(self, mocker):
+        checker_mock = mocker.Mock()
+        checker_mock.live_data_properties.extent.spatialReference.latestWkid = 42
+        checker_mock.new_dataframe.spatial.sr.wkid = 42
+
+        #: Should not raise
+        palletjack.utils.FieldChecker.check_srs_match(checker_mock)
+
+    def test_check_srs_match_raises_on_mismatch(self, mocker):
+        checker_mock = mocker.Mock()
+        checker_mock.live_data_properties.extent.spatialReference.latestWkid = 42
+        checker_mock.new_dataframe.spatial.sr.wkid = 8
+
+        with pytest.raises(ValueError, match=re.escape('New dataframe SRS 8 does not match live SRS 42')):
+            palletjack.utils.FieldChecker.check_srs_match(checker_mock)
+
+    def test_check_srs_match_handles_string_and_int(self, mocker):
+        checker_mock = mocker.Mock()
+        checker_mock.live_data_properties.extent.spatialReference.latestWkid = 42
+        checker_mock.new_dataframe.spatial.sr.wkid = '42'
+
+        #: should not raise
+        palletjack.utils.FieldChecker.check_srs_match(checker_mock)
+
+    def test_check_srs_match_reports_uncastable_string(self, mocker):
+        checker_mock = mocker.Mock()
+        checker_mock.live_data_properties.extent.spatialReference.latestWkid = 42
+        checker_mock.new_dataframe.spatial.sr.wkid = 'forty two'
+
+        with pytest.raises(ValueError, match=re.escape('Could not cast either new or existing SRS to int')) as exc_info:
+            palletjack.utils.FieldChecker.check_srs_match(checker_mock)
+
+        assert hasattr(exc_info.value, '__cause__')
+        assert isinstance(exc_info.value.__cause__, ValueError)
+        assert 'invalid literal for int() with base 10:' in str(exc_info.value.__cause__)
+
+
+class TestNullGeometryGenerators:
+
+    def test_get_null_geometries_point(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.geometryType = 'esriGeometryPoint'
+        properties_mock.extent.spatialReference.latestWkid = 4326
+
+        nullo = palletjack.utils.get_null_geometries(properties_mock)
+
+        assert nullo == '{"x": 0, "y": 0, "spatialReference": {"wkid": 4326}}'
+
+    def test_get_null_geometries_polyline(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.geometryType = 'esriGeometryPolyline'
+        properties_mock.extent.spatialReference.latestWkid = 4326
+
+        nullo = palletjack.utils.get_null_geometries(properties_mock)
+
+        assert nullo == '{"paths": [[[0, 0], [0.1, 0.1], [0.2, 0.2]]], "spatialReference": {"wkid": 4326}}'
+
+    def test_get_null_geometries_polygon(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.geometryType = 'esriGeometryPolygon'
+        properties_mock.extent.spatialReference.latestWkid = 4326
+
+        nullo = palletjack.utils.get_null_geometries(properties_mock)
+
+        assert nullo == '{"rings": [[[0, 0.1], [0.1, 0.1], [0.1, 0], [0, 0]]], "spatialReference": {"wkid": 4326}}'
+
+    def test_get_null_geometries_raises_on_other(self, mocker):
+        properties_mock = mocker.Mock()
+        properties_mock.geometryType = 'other'
+
+        with pytest.raises(NotImplementedError) as exc_info:
+            nullo = palletjack.utils.get_null_geometries(properties_mock)
+
+        assert 'Null value generator for live geometry type other not yet implemented' in str(exc_info.value)
+
+
+class TestDeleteUtils:
+
+    def test_check_delete_oids_are_ints_casts_string_list_with_spaces(self):
+        oid_list = [' 1', ' 2', '3']
+
+        numeric_oids = palletjack.utils.DeleteUtils.check_delete_oids_are_ints(oid_list)
+
+        assert numeric_oids == [1, 2, 3]
+
+    def test_check_delete_oids_are_ints_casts_string_list_without_spaces(self):
+        oid_list = ['1', '2', '3']
+
+        numeric_oids = palletjack.utils.DeleteUtils.check_delete_oids_are_ints(oid_list)
+
+        assert numeric_oids == [1, 2, 3]
+
+    def test_check_delete_oids_are_ints_passes_on_int_list(self):
+        oid_list = [1, 2, 3]
+
+        numeric_oids = palletjack.utils.DeleteUtils.check_delete_oids_are_ints(oid_list)
+
+        assert numeric_oids == [1, 2, 3]
+
+    def test_check_delete_oids_are_ints_raises_first_non_int(self):
+        oid_list = ['one', 2, 3]
+
+        with pytest.raises(TypeError) as exc_info:
+            palletjack.utils.DeleteUtils.check_delete_oids_are_ints(oid_list)
+
+        assert 'Couldn\'t convert OBJECTID(s) `[\'one\']` to integer' in str(exc_info.value)
+
+    def test_check_delete_oids_are_ints_raises_later_non_ints(self):
+        oid_list = [1, 'two', 'three']
+
+        with pytest.raises(TypeError) as exc_info:
+            palletjack.utils.DeleteUtils.check_delete_oids_are_ints(oid_list)
+
+        assert 'Couldn\'t convert OBJECTID(s) `[\'two\', \'three\']` to integer' in str(exc_info.value)
+
+    def test_check_for_empty_oid_list_doesnt_raise_on_list(self):
+        oid_list = [1, 2, 3]
+        numeric_oids = [1, 2, 3]
+
+        palletjack.utils.DeleteUtils.check_for_empty_oid_list(oid_list, numeric_oids)
+
+    def test_check_for_empty_oid_list_raises_on_empty_list(self):
+        oid_list = []
+        numeric_oids = []
+
+        with pytest.raises(ValueError) as exc_info:
+            palletjack.utils.DeleteUtils.check_for_empty_oid_list(oid_list, numeric_oids)
+
+        assert 'No OBJECTIDs found in []' in str(exc_info.value)
+
+    def test_check_delete_oids_are_in_live_data_doesnt_warn_on_good_oids(self, mocker):
+        fl_mock = mocker.Mock()
+        fl_mock.query.return_value = {'objectIdFieldName': 'OBJECTID', 'objectIds': [1, 2, 3]}
+        oid_list = [1, 2, 3]
+        oid_numeric = [1, 2, 3]
+
+        palletjack.utils.DeleteUtils.check_delete_oids_are_in_live_data(oid_list, oid_numeric, fl_mock)
+
+    def test_check_delete_oids_are_in_live_data_warns_on_missing_oid(self, mocker):
+        fl_mock = mocker.Mock()
+        fl_mock.query.return_value = {'objectIdFieldName': 'OBJECTID', 'objectIds': [1, 2]}
+        oid_list = [1, 2, 3]
+        oid_numeric = [1, 2, 3]
+
+        with pytest.warns(UserWarning, match=re.escape('OBJECTIDs {3} were not found in the live data')):
+            number_of_missing_oids = palletjack.utils.DeleteUtils.check_delete_oids_are_in_live_data(
+                oid_list, oid_numeric, fl_mock
+            )
+
+        assert number_of_missing_oids == 1
+
+
+class TestSaveDataframeToJSON:
+
+    def test_save_feature_layer_to_json_calls_write_with_json_and_returns_right_path(self, mocker):
+
+        mock_df = pd.DataFrame({
+            'foo': [1],
+            'x': [11],
+            'y': [14],
+        })
+
+        mock_sdf = pd.DataFrame.spatial.from_xy(mock_df, 'x', 'y')
+        mock_fl = mocker.Mock()
+        mock_fl.query.return_value.sdf = mock_sdf
+        mock_fl.properties.name = 'flayer'
+
+        open_mock = mocker.MagicMock()
+        context_manager_mock = mocker.MagicMock()
+        context_manager_mock.return_value.__enter__.return_value = open_mock
+        mocker.patch('palletjack.utils.Path.open', new=context_manager_mock)
+
+        out_path = palletjack.utils.save_feature_layer_to_json(mock_fl, 'foo')
+
+        test_json_string = '{"features": [{"geometry": {"spatialReference": {"wkid": 4326}, "x": 11, "y": 14}, "attributes": {"foo": 1, "x": 11, "y": 14, "OBJECTID": 1}}], "objectIdFieldName": "OBJECTID", "displayFieldName": "OBJECTID", "spatialReference": {"wkid": 4326}, "geometryType": "esriGeometryPoint", "fields": [{"name": "OBJECTID", "type": "esriFieldTypeOID", "alias": "OBJECTID"}, {"name": "foo", "type": "esriFieldTypeDouble", "alias": "foo"}, {"name": "x", "type": "esriFieldTypeDouble", "alias": "x"}, {"name": "y", "type": "esriFieldTypeDouble", "alias": "y"}]}'
+
+        open_mock.write.assert_called_with(test_json_string)
+        assert out_path == Path('foo', f'flayer_{datetime.date.today()}.json')
+
+
+class TestDataFrameChunking:
+
+    def test_ceildiv_always_returns_at_least_one(self):
+        assert palletjack.utils.Chunking._ceildiv(1, 2) == 1
+
+    def test_ceildiv_returns_ceiling(self):
+        assert palletjack.utils.Chunking._ceildiv(3, 2) == 2
+
+    def test_chunk_dataframe_properly_chunks(self, iris):
+
+        chunks = 4
+        chunk_size = palletjack.utils.Chunking._ceildiv(len(iris), chunks)
+        dfs = palletjack.utils.Chunking._chunk_dataframe(iris, chunk_size)
+
+        assert len(dfs) == chunks
+        assert [len(df) for df in dfs] == [38, 38, 38, 36]
+
+    def test_chunk_dataframe_properly_chunks_even_sized_chunks(self, iris):
+
+        chunks = 5
+        chunk_size = palletjack.utils.Chunking._ceildiv(len(iris), chunks)
+        dfs = palletjack.utils.Chunking._chunk_dataframe(iris, chunk_size)
+
+        assert len(dfs) == chunks
+        assert [len(df) for df in dfs] == [30] * chunks
+
+    def test_recursive_dataframe_chunking_recurses_on_first_chunk_too_large(self, mocker):
+        #: 5 rows, 2 chunks, first chunk gets 4 instead of three.
+        #: two total calls: once for 5 elements, once for 4. first call chunks to 4 and 1, next chunks 4 into 3 and 1
+        df = pd.DataFrame(['a', 'b', 'c', 'd', 'e'], columns=['foo'])
+        mocker.patch('palletjack.utils.pd.DataFrame.spatial.to_featureset', return_value=mocker.Mock())
+        mocker.patch('palletjack.utils.Chunking._ceildiv')
+        sys_mock = mocker.patch('palletjack.utils.sys.getsizeof', side_effect=['foo', 4, 'foo', 2, 2, 1])
+        mocker.patch(
+            'palletjack.utils.Chunking._chunk_dataframe',
+            side_effect=[
+                [df.iloc[:4], df.iloc[4:]],  #: first chunking gives 4 and 1
+                [df.iloc[:3], df.iloc[3:4]],  #: second chunking gives 3 and 1 (3 being the max size)
+            ]
+        )
+        df_list = palletjack.utils.Chunking._recursive_dataframe_chunking(df, 3)
+
+        tm.assert_frame_equal(df_list[0], df.iloc[:3])
+        tm.assert_frame_equal(df_list[1], df.iloc[3:4])
+        tm.assert_frame_equal(df_list[2], df.iloc[4:])
+        assert sys_mock.call_count == 6
+
+    def test_recursive_dataframe_chunking_recurses_on_second_chunk_too_large(self, mocker):
+        #: 5 rows, 2 chunks, first chunk gets 1 instead of three, second gets 4
+        #: two total calls: once for 5 elements, once for 4. first call chunks to 1 and 4, next chunks 4 into 3 and 1
+        df = pd.DataFrame(['a', 'b', 'c', 'd', 'e'], columns=['foo'])
+        mocker.patch('palletjack.utils.pd.DataFrame.spatial.to_featureset', return_value=mocker.Mock())
+        mocker.patch('palletjack.utils.Chunking._ceildiv')
+        sys_mock = mocker.patch('palletjack.utils.sys.getsizeof', side_effect=['foo', 1, 4, 'foo', 2, 2])
+        mocker.patch(
+            'palletjack.utils.Chunking._chunk_dataframe',
+            side_effect=[
+                [df.iloc[:1], df.iloc[1:]],  #: first chunking gives 1 and 4
+                [df.iloc[1:4], df.iloc[4:]],  #: second chunking gives 3 and 1 (3 being the max size)
+            ]
+        )
+
+        df_list = palletjack.utils.Chunking._recursive_dataframe_chunking(df, 3)
+
+        tm.assert_frame_equal(df_list[0], df.iloc[:1])
+        tm.assert_frame_equal(df_list[1], df.iloc[1:4])
+        tm.assert_frame_equal(df_list[2], df.iloc[4:])
+        assert sys_mock.call_count == 6
+
+    def test_recursive_dataframe_chunking_recurses_on_middle_chunk_too_large(self, mocker):
+        #: 5 rows, 3 chunks, first chunk gets 1, second gets 3, third gets 1
+        #: two total calls: once for 5 elements, once for 3.
+        df = pd.DataFrame(['a', 'b', 'c', 'd', 'e'], columns=['foo'])
+        mocker.patch('palletjack.utils.pd.DataFrame.spatial.to_featureset', return_value=mocker.Mock())
+        mocker.patch('palletjack.utils.Chunking._ceildiv')
+        sys_mock = mocker.patch('palletjack.utils.sys.getsizeof', side_effect=['foo', 1, 3, 'foo', 2, 1, 1])
+        mocker.patch(
+            'palletjack.utils.Chunking._chunk_dataframe',
+            side_effect=[
+                [df.iloc[:1], df.iloc[1:4], df.iloc[4:]],  #: first chunking gives 1, 3, 1
+                [df.iloc[1:3], df.iloc[3:4]],  #: second chunking gives 2 and 1 (2 being the max size)
+            ]
+        )
+
+        df_list = palletjack.utils.Chunking._recursive_dataframe_chunking(df, 2)
+
+        tm.assert_frame_equal(df_list[0], df.iloc[:1])
+        tm.assert_frame_equal(df_list[1], df.iloc[1:3])
+        tm.assert_frame_equal(df_list[2], df.iloc[3:4])
+        tm.assert_frame_equal(df_list[3], df.iloc[4:])
+        assert sys_mock.call_count == 7
+
+    def test_recursive_dataframe_chunking_doesnt_recurse_when_not_needed(self, mocker):
+        #: 5 rows, 2 chunks, first chunk gets 3, second gets 2
+        #: only one call: first breaks into 3 and 2
+        df = pd.DataFrame(['a', 'b', 'c', 'd', 'e'], columns=['foo'])
+        mocker.patch('palletjack.utils.pd.DataFrame.spatial.to_featureset', return_value=mocker.Mock())
+        mocker.patch('palletjack.utils.Chunking._ceildiv')
+        sys_mock = mocker.patch('palletjack.utils.sys.getsizeof', side_effect=['foo', 3, 2])
+        mocker.patch(
+            'palletjack.utils.Chunking._chunk_dataframe',
+            side_effect=[
+                [df.iloc[:3], df.iloc[3:]],  #: first chunking gives 3, 2
+            ]
+        )
+
+        df_list = palletjack.utils.Chunking._recursive_dataframe_chunking(df, 3)
+
+        tm.assert_frame_equal(df_list[0], df.iloc[:3])
+        tm.assert_frame_equal(df_list[1], df.iloc[3:])
+        assert sys_mock.call_count == 3
+
+    def test_recursive_dataframe_chunking_doesnt_chunk_when_not_needed(self, mocker):
+        #: 5 rows, 2 chunks, first chunk gets 3, second gets 2
+        #: only one call: first breaks into 3 and 2
+        df = pd.DataFrame(['a', 'b', 'c', 'd', 'e'], columns=['foo'])
+        mocker.patch('palletjack.utils.pd.DataFrame.spatial.to_featureset', return_value=mocker.Mock())
+        mocker.patch('palletjack.utils.Chunking._ceildiv')
+        sys_mock = mocker.patch('palletjack.utils.sys.getsizeof', side_effect=['foo', 5])
+        chunking_mock = mocker.patch(
+            'palletjack.utils.Chunking._chunk_dataframe',
+            side_effect=[
+                [df],  #: no chunk needed
+            ]
+        )
+
+        df_list = palletjack.utils.Chunking._recursive_dataframe_chunking(df, 5)
+
+        tm.assert_frame_equal(df_list[0], df)
+        chunking_mock.assert_called_once()
+        assert sys_mock.call_count == 2
+
+    def test_build_upload_json_calls_null_string_fixer_appropriate_number_of_times(self, mocker):
+
+        mock_df = mocker.Mock()
+        mock_string_fixer = mocker.patch('palletjack.utils.fix_numeric_empty_strings')
+        mock_string_fixer.return_value.to_geojson = 'new_json'
+        mocker.patch('palletjack.utils.sys.getsizeof')
+        mocker.patch('palletjack.utils.Chunking._recursive_dataframe_chunking', return_value=[mocker.Mock()] * 5)
+
+        json_list = palletjack.utils.Chunking.build_upload_json(mock_df, 'foo_dict')
+
+        assert len(json_list) == 5
+        assert json_list == ['new_json'] * 5
+        assert mock_string_fixer.call_count == 5